diff --git a/.github/workflows/update-mpt-crypto.yml b/.github/workflows/update-mpt-crypto.yml new file mode 100644 index 000000000..e58a9824a --- /dev/null +++ b/.github/workflows/update-mpt-crypto.yml @@ -0,0 +1,394 @@ +name: Build mpt-crypto + +on: + workflow_dispatch: + inputs: + version: + description: "mpt-crypto version (leave empty for latest)" + required: false + default: "" + force: + description: "Rebuild even when the version or update branch already exists" + required: false + type: boolean + default: true + schedule: + - cron: "0 9 * * 1" # Every Monday at 9:00 UTC + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.resolve.outputs.version }} + current: ${{ steps.resolve.outputs.current }} + needed: ${{ steps.resolve.outputs.needed }} + branch: ${{ steps.resolve.outputs.branch }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Install Conan + run: | + pip install "conan==2.26.2" + conan profile detect + conan remote add --index 0 xrplf https://conan.ripplex.io + + - name: Resolve version and check if update needed + id: resolve + env: + REQUESTED_VERSION: ${{ inputs.version }} + FORCE_UPDATE: ${{ github.event_name == 'workflow_dispatch' && inputs.force }} + run: | + set -euo pipefail + + CURRENT=$(cat confidential/deps/VERSION 2>/dev/null || echo "") + echo "current=$CURRENT" >> "$GITHUB_OUTPUT" + + if [ -n "$REQUESTED_VERSION" ]; then + VERSION="$REQUESTED_VERSION" + else + VERSION=$(conan list 'mpt-crypto/*' -r xrplf --format=json \ + | python3 -c ' + import json + import re + import sys + + packages = json.load(sys.stdin) + versions = [ + reference.split("/", 1)[1] + for remote in packages.values() + for reference in remote + if reference.startswith("mpt-crypto/") + ] + if not versions: + raise SystemExit("no mpt-crypto versions found") + + def version_key(version): + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?", version) + if not match: + raise SystemExit(f"unsupported mpt-crypto version format: {version}") + prerelease = match.group(4) + prerelease_key = tuple( + (0, int(part)) if part.isdigit() else (1, part) + for part in re.split(r"[.-]", prerelease or "") + ) + return (*(int(match.group(index)) for index in range(1, 4)), prerelease is None, prerelease_key) + + print(max(versions, key=version_key)) + ') + fi + + if [[ ! "$VERSION" =~ ^[0-9A-Za-z][0-9A-Za-z._+-]*$ ]]; then + echo "::error::Invalid mpt-crypto version '$VERSION'" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + BUNDLE_COMPLETE=true + for platform in linux-amd64 linux-arm64 darwin-amd64 darwin-arm64; do + for archive in libmpt-crypto.a libsecp256k1.a libcrypto.a; do + [ -s "confidential/deps/libs/$platform/$archive" ] || BUNDLE_COMPLETE=false + done + done + for header in mpt_protocol.h secp256k1_mpt.h secp256k1.h utility/mpt_utility.h; do + [ -s "confidential/deps/include/$header" ] || BUNDLE_COMPLETE=false + done + if [ "$BUNDLE_COMPLETE" = true ] && ! cc \ + -Iconfidential/deps/include \ + -Iconfidential/deps/include/utility \ + -x c -fsyntax-only - >/dev/null 2>&1 <<'EOF'; then + #include "mpt_utility.h" + int main(void) { return 0; } + EOF + BUNDLE_COMPLETE=false + fi + + if [ "$FORCE_UPDATE" = "true" ]; then + BRANCH="chore/update-mpt-crypto-${VERSION}-run-${GITHUB_RUN_ID}" + echo "needed=true" >> "$GITHUB_OUTPUT" + echo "Forced rebuild requested for mpt-crypto/$VERSION." + else + BRANCH="chore/update-mpt-crypto-${VERSION}" + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + echo "needed=false" >> "$GITHUB_OUTPUT" + echo "::warning::Branch '$BRANCH' already exists. Enable force to create a new update branch." + elif [ "$CURRENT" = "$VERSION" ] && [ "$BUNDLE_COMPLETE" = true ]; then + echo "needed=false" >> "$GITHUB_OUTPUT" + echo "Already at a complete mpt-crypto/$VERSION bundle — skipping build." + else + echo "needed=true" >> "$GITHUB_OUTPUT" + echo "Building mpt-crypto/$VERSION for bundle validation ($CURRENT -> $VERSION)." + fi + fi + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Lock resolved Conan graph + if: steps.resolve.outputs.needed == 'true' + env: + MPT_CRYPTO_VERSION: ${{ steps.resolve.outputs.version }} + run: | + set -euo pipefail + mkdir -p .mpt-crypto-lock + cat > .mpt-crypto-lock/conanfile.txt < "$conan_home/settings_user.yml" </dev/null + + conan remote add --index 0 xrplf https://conan.ripplex.io + + - name: Build CGO dependency bundle + env: + MPT_CRYPTO_VERSION: ${{ needs.check.outputs.version }} + TARGET_PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + bash confidential/deps/update.sh \ + --platform "$TARGET_PLATFORM" \ + --force \ + --version "$MPT_CRYPTO_VERSION" \ + --lockfile "$GITHUB_WORKSPACE/.mpt-crypto-lock/conan.lock" + + - name: Verify CGO dependency bundle + env: + TARGET_PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + for archive in libmpt-crypto.a libsecp256k1.a libcrypto.a; do + path="confidential/deps/libs/$TARGET_PLATFORM/$archive" + test -s "$path" + ar -t "$path" >/dev/null + done + + header_manifest="$GITHUB_WORKSPACE/confidential/deps/MANIFEST.sha256" + ( + cd confidential/deps/include + find . -type f -name '*.h' -print | + LC_ALL=C sort | + while IFS= read -r header; do shasum -a 256 "$header"; done + ) > "$header_manifest" + (cd confidential/deps/include && shasum -a 256 -c "$header_manifest") + + cc \ + -Iconfidential/deps/include \ + -Iconfidential/deps/include/utility \ + -x c -fsyntax-only - <<'EOF' + #include "mpt_utility.h" + int main(void) { return 0; } + EOF + + - name: Upload platform artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mpt-crypto-${{ matrix.platform }} + if-no-files-found: error + path: | + confidential/deps/VERSION + confidential/deps/MANIFEST.sha256 + confidential/deps/include/ + confidential/deps/libs/${{ matrix.platform }}/ + + pr: + needs: [check, build] + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Download all platform artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: artifacts/ + + - name: Validate and merge artifacts into repo + env: + EXPECTED_VERSION: ${{ needs.check.outputs.version }} + run: | + set -euo pipefail + + merge_dir=$(mktemp -d) + trap 'rm -rf "$merge_dir"' EXIT + mkdir -p "$merge_dir/libs" + + header_source="" + manifest_source="" + artifact_count=0 + for dir in artifacts/mpt-crypto-*/; do + artifact_count=$((artifact_count + 1)) + platform=$(basename "$dir" | sed 's/mpt-crypto-//') + case "$platform" in + linux-amd64|linux-arm64|darwin-amd64|darwin-arm64) ;; + *) echo "::error::Unexpected platform artifact: $platform"; exit 1 ;; + esac + + actual_version=$(cat "$dir/VERSION") + if [ "$actual_version" != "$EXPECTED_VERSION" ]; then + echo "::error::$platform artifact has version $actual_version, expected $EXPECTED_VERSION" + exit 1 + fi + + source_libs="$dir/libs/$platform" + archive_count=$(find "$source_libs" -maxdepth 1 -type f -name '*.a' | wc -l | tr -d ' ') + if [ "$archive_count" -ne 3 ]; then + echo "::error::$platform artifact contains $archive_count archives, expected 3" + exit 1 + fi + + mkdir -p "$merge_dir/libs/$platform" + for archive in libmpt-crypto.a libsecp256k1.a libcrypto.a; do + if [ ! -s "$source_libs/$archive" ]; then + echo "::error::$platform artifact is missing $archive" + exit 1 + fi + cp "$source_libs/$archive" "$merge_dir/libs/$platform/" + done + + artifact_manifest="$dir/MANIFEST.sha256" + if [ ! -s "$artifact_manifest" ]; then + echo "::error::$platform artifact is missing its header checksum manifest" + exit 1 + fi + artifact_manifest=$(cd "$(dirname "$artifact_manifest")" && pwd)/$(basename "$artifact_manifest") + (cd "$dir/include" && shasum -a 256 -c "$artifact_manifest") + + if [ -z "$header_source" ]; then + header_source="$dir/include" + manifest_source="$artifact_manifest" + elif ! cmp -s "$manifest_source" "$artifact_manifest"; then + echo "::error::Public header checksums differ between platform artifacts" + exit 1 + elif ! diff -qr "$header_source" "$dir/include"; then + echo "::error::Public headers differ between platform artifacts" + exit 1 + fi + done + + if [ "$artifact_count" -ne 4 ]; then + echo "::error::Found $artifact_count platform artifacts, expected 4" + exit 1 + fi + + mkdir -p "$merge_dir/include" + cp -R "$header_source/." "$merge_dir/include/" + for header in mpt_protocol.h secp256k1_mpt.h secp256k1.h utility/mpt_utility.h; do + if [ ! -s "$merge_dir/include/$header" ]; then + echo "::error::Merged artifact is missing public header $header" + exit 1 + fi + done + + rm -rf confidential/deps/libs confidential/deps/include + cp -R "$merge_dir/libs" confidential/deps/libs + cp -R "$merge_dir/include" confidential/deps/include + printf '%s\n' "$EXPECTED_VERSION" > confidential/deps/VERSION + + - name: Create PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.check.outputs.version }} + CURRENT_VERSION: ${{ needs.check.outputs.current }} + UPDATE_BRANCH: ${{ needs.check.outputs.branch }} + BASE_BRANCH: ${{ github.ref_name }} + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$UPDATE_BRANCH" + git add -f confidential/deps/ + if git diff --cached --quiet; then + echo "The rebuilt dependency bundle is unchanged; no PR is required." + exit 0 + fi + git commit -m "chore(confidential): update vendored mpt-crypto to $VERSION" + gh auth setup-git + git push -u origin "$UPDATE_BRANCH" + + gh pr create \ + --base "$BASE_BRANCH" \ + --title "Update vendored mpt-crypto to $VERSION" \ + --body "$(cat < p.CurrentBalance { + return nil, ErrInsufficientBalance + } + + bf, err := elgamal.GenerateBlindingFactor() + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + + // Encrypt withdrawal amount under holder, issuer, and optionally auditor keys. + holderCt, err := elgamal.Encrypt(p.Amount, p.HolderPubKey, bf) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + issuerCt, err := elgamal.Encrypt(p.Amount, p.IssuerPubKey, bf) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + + var auditorCt string + if p.AuditorPubKey != "" { + auditorCt, err = elgamal.Encrypt(p.Amount, p.AuditorPubKey, bf) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + } + + // Generate a fresh blinding factor for balance commitment. + // This ensures correctness even after MergeInbox operations. + balanceBF, err := elgamal.GenerateBlindingFactor() + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + + // Create balance commitment from current balance state with fresh BF. + balanceCommit, err := commitment.Create(p.CurrentBalance, balanceBF) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + + ctxHash, err := proof.ConvertBackContextHash(p.Account, p.IssuanceID, p.Sequence, p.BalanceVersion) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + + balanceProofParams := proof.Params{ + CommitmentHex: balanceCommit, + Amount: p.CurrentBalance, + CiphertextHex: p.CurrentBalanceCt, + BlindingFactorHex: balanceBF, + } + + proofHex, err := proof.GenerateConvertBackProof(p.HolderPrivKey, p.HolderPubKey, ctxHash, p.Amount, balanceProofParams) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + + tx := &transaction.ConfidentialMPTConvertBack{ + BaseTx: transaction.BaseTx{ + Account: types.Address(p.Account), + TransactionType: transaction.ConfidentialMPTConvertBackTx, + Sequence: p.Sequence, + }, + MPTokenIssuanceID: p.IssuanceID, + MPTAmount: types.MPTPlainAmount(p.Amount), + HolderEncryptedAmount: holderCt, + IssuerEncryptedAmount: issuerCt, + BlindingFactor: bf, + BalanceCommitment: balanceCommit, + ZKProof: proofHex, + } + + if auditorCt != "" { + tx.AuditorEncryptedAmount = &auditorCt + } + + return tx, nil +} diff --git a/confidential/builder/convert_back_test.go b/confidential/builder/convert_back_test.go new file mode 100644 index 000000000..f532d6466 --- /dev/null +++ b/confidential/builder/convert_back_test.go @@ -0,0 +1,226 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package builder + +import ( + "strings" + "testing" + + "github.com/Peersyst/xrpl-go/confidential/elgamal" + "github.com/Peersyst/xrpl-go/confidential/proof" + "github.com/Peersyst/xrpl-go/xrpl/transaction" + "github.com/stretchr/testify/require" +) + +// TestConvertBackBaseValidation verifies all validateConvertBackBase branches through both entry points. +func TestConvertBackBaseValidation(t *testing.T) { + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + cases := []struct { + name string + base BuildConvertBackParams + wantErr error + }{ + {name: "fail - missing account", base: BuildConvertBackParams{IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex}, wantErr: ErrMissingAccount}, + {name: "fail - invalid account", base: BuildConvertBackParams{Account: "notanaddress", IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidAccount}, + {name: "fail - missing issuance ID", base: BuildConvertBackParams{Account: testAccount, Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex}, wantErr: ErrMissingIssuanceID}, + {name: "fail - invalid issuance ID (not hex)", base: BuildConvertBackParams{Account: testAccount, IssuanceID: strings.Repeat("GG", 24), Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidIssuanceID}, + {name: "fail - invalid issuance ID (wrong length)", base: BuildConvertBackParams{Account: testAccount, IssuanceID: "aabb", Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidIssuanceID}, + {name: "fail - zero amount", base: BuildConvertBackParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 0, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex}, wantErr: ErrZeroAmount}, + {name: "fail - missing holder priv key", base: BuildConvertBackParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPubKey: kp.PubKeyHex}, wantErr: ErrMissingHolderKey}, + {name: "fail - invalid holder priv key (not hex)", base: BuildConvertBackParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: strings.Repeat("ZZ", 32), HolderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidPrivKey}, + {name: "fail - invalid holder priv key (wrong length)", base: BuildConvertBackParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: "aabb", HolderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidPrivKey}, + {name: "fail - missing holder pub key", base: BuildConvertBackParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: kp.PrivKeyHex}, wantErr: ErrMissingHolderKey}, + {name: "fail - invalid holder pub key (not hex)", base: BuildConvertBackParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: strings.Repeat("ZZ", 33)}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid holder pub key (wrong length)", base: BuildConvertBackParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: "aabb"}, wantErr: ErrInvalidPubKey}, + } + + t.Run("fail - validation PrepareConvertBack", func(t *testing.T) { + issKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + ct, err := elgamal.Encrypt(100, kp.PubKeyHex, bf) + require.NoError(t, err) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := PrepareConvertBack(ConvertBackParams{ + BuildConvertBackParams: tc.base, + IssuerPubKey: issKP.PubKeyHex, + CurrentBalance: 100, + CurrentBalanceCt: ct, + }) + require.ErrorIs(t, err, tc.wantErr) + }) + } + }) + + t.Run("fail - validation BuildConvertBack", func(t *testing.T) { + q := &mockQuerier{} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := BuildConvertBack(q, tc.base) + require.ErrorIs(t, err, tc.wantErr) + }) + } + }) +} + +func TestPrepareConvertBack_Pass(t *testing.T) { + const currentBalance uint64 = 1000 + const withdrawAmount uint64 = 100 + + holderKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + issuerKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + // Simulate existing balance state. + balanceBF, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + balanceCt, err := elgamal.Encrypt(currentBalance, holderKP.PubKeyHex, balanceBF) + require.NoError(t, err) + + result, err := PrepareConvertBack(ConvertBackParams{ + BuildConvertBackParams: BuildConvertBackParams{ + Account: testAccount, + IssuanceID: testIssuanceID, + Amount: withdrawAmount, + HolderPrivKey: holderKP.PrivKeyHex, + HolderPubKey: holderKP.PubKeyHex, + }, + IssuerPubKey: issuerKP.PubKeyHex, + Sequence: 1, + BalanceVersion: 0, + CurrentBalance: currentBalance, + CurrentBalanceCt: balanceCt, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, transaction.ConfidentialMPTConvertBackTx, result.TxType()) + + // Verify the linkage + range proof cryptographically. + ctxHash, err := proof.ConvertBackContextHash(testAccount, testIssuanceID, uint32(1), uint32(0)) + require.NoError(t, err) + err = proof.VerifyConvertBackProof(result.ZKProof, holderKP.PubKeyHex, balanceCt, result.BalanceCommitment, withdrawAmount, ctxHash) + require.NoError(t, err) + + ok, err := result.Validate() + require.NoError(t, err) + require.True(t, ok) +} + +func TestPrepareConvertBack_FailInsufficientBalance(t *testing.T) { + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + issKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + ct, err := elgamal.Encrypt(100, kp.PubKeyHex, bf) + require.NoError(t, err) + + _, err = PrepareConvertBack(ConvertBackParams{ + BuildConvertBackParams: BuildConvertBackParams{ + Account: testAccount, + IssuanceID: testIssuanceID, + Amount: 200, // More than CurrentBalance (100) + HolderPrivKey: kp.PrivKeyHex, + HolderPubKey: kp.PubKeyHex, + }, + IssuerPubKey: issKP.PubKeyHex, + Sequence: 1, + CurrentBalance: 100, + CurrentBalanceCt: ct, + }) + require.ErrorIs(t, err, ErrInsufficientBalance) +} + +func TestPrepareConvertBack_FailValidation(t *testing.T) { + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + issKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + ct, err := elgamal.Encrypt(100, kp.PubKeyHex, bf) + require.NoError(t, err) + + base := BuildConvertBackParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex} + + tests := []struct { + name string + params ConvertBackParams + wantErr error + }{ + {name: "fail - missing issuer pub key", params: ConvertBackParams{BuildConvertBackParams: base, CurrentBalance: 100, CurrentBalanceCt: ct}, wantErr: ErrMissingIssuerKey}, + {name: "fail - invalid issuer pub key (not hex)", params: ConvertBackParams{BuildConvertBackParams: base, IssuerPubKey: strings.Repeat("ZZ", 33), CurrentBalance: 100, CurrentBalanceCt: ct}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid issuer pub key (wrong length)", params: ConvertBackParams{BuildConvertBackParams: base, IssuerPubKey: "aabb", CurrentBalance: 100, CurrentBalanceCt: ct}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid auditor pub key (not hex)", params: ConvertBackParams{BuildConvertBackParams: base, IssuerPubKey: issKP.PubKeyHex, AuditorPubKey: strings.Repeat("ZZ", 33), CurrentBalance: 100, CurrentBalanceCt: ct}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid auditor pub key (wrong length)", params: ConvertBackParams{BuildConvertBackParams: base, IssuerPubKey: issKP.PubKeyHex, AuditorPubKey: "aabb", CurrentBalance: 100, CurrentBalanceCt: ct}, wantErr: ErrInvalidPubKey}, + {name: "fail - missing sender state", params: ConvertBackParams{BuildConvertBackParams: base, IssuerPubKey: issKP.PubKeyHex, CurrentBalance: 100}, wantErr: ErrMissingSenderState}, + {name: "fail - invalid ciphertext (not hex)", params: ConvertBackParams{BuildConvertBackParams: base, IssuerPubKey: issKP.PubKeyHex, CurrentBalance: 100, CurrentBalanceCt: strings.Repeat("ZZ", 66)}, wantErr: ErrInvalidCiphertext}, + {name: "fail - invalid ciphertext (wrong length)", params: ConvertBackParams{BuildConvertBackParams: base, IssuerPubKey: issKP.PubKeyHex, CurrentBalance: 100, CurrentBalanceCt: "aabb"}, wantErr: ErrInvalidCiphertext}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := PrepareConvertBack(tt.params) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestBuildConvertBack_BalanceRange(t *testing.T) { + const currentBalance uint64 = 1000 + + 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}, + } + + 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) + + q := &mockQuerier{accountErr: ErrLedgerQuery} + _, err = BuildConvertBack(q, BuildConvertBackParams{ + Account: testAccount, + IssuanceID: testIssuanceID, + Amount: 1, + HolderPrivKey: holderKP.PrivKeyHex, + HolderPubKey: holderKP.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") +} diff --git a/confidential/builder/convert_test.go b/confidential/builder/convert_test.go new file mode 100644 index 000000000..88c893339 --- /dev/null +++ b/confidential/builder/convert_test.go @@ -0,0 +1,276 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package builder + +import ( + "strings" + "testing" + + "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" +) + +// TestConvertBaseValidation verifies all validateConvertBase branches through both entry points. +func TestConvertBaseValidation(t *testing.T) { + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + cases := []struct { + name string + base BuildConvertParams + wantErr error + }{ + {name: "fail - missing account", base: BuildConvertParams{IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex}, wantErr: ErrMissingAccount}, + {name: "fail - invalid account", base: BuildConvertParams{Account: "notanaddress", IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidAccount}, + {name: "fail - missing issuance ID", base: BuildConvertParams{Account: testAccount, Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex}, wantErr: ErrMissingIssuanceID}, + {name: "fail - invalid issuance ID (not hex)", base: BuildConvertParams{Account: testAccount, IssuanceID: strings.Repeat("GG", 24), Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidIssuanceID}, + {name: "fail - invalid issuance ID (wrong length)", base: BuildConvertParams{Account: testAccount, IssuanceID: "aabb", Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidIssuanceID}, + {name: "fail - missing holder priv key", base: BuildConvertParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPubKey: kp.PubKeyHex}, wantErr: ErrMissingHolderKey}, + {name: "fail - invalid holder priv key (not hex)", base: BuildConvertParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: strings.Repeat("ZZ", 32), HolderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidPrivKey}, + {name: "fail - invalid holder priv key (wrong length)", base: BuildConvertParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: "aabb", HolderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidPrivKey}, + {name: "fail - missing holder pub key", base: BuildConvertParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: kp.PrivKeyHex}, wantErr: ErrMissingHolderKey}, + {name: "fail - invalid holder pub key (not hex)", base: BuildConvertParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: strings.Repeat("ZZ", 33)}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid holder pub key (wrong length)", base: BuildConvertParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: "aabb"}, wantErr: ErrInvalidPubKey}, + } + + t.Run("fail - validation PrepareConvert", func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := PrepareConvert(ConvertParams{BuildConvertParams: tc.base, IssuerPubKey: kp.PubKeyHex}) + require.ErrorIs(t, err, tc.wantErr) + }) + } + }) + + t.Run("fail - validation BuildConvert", func(t *testing.T) { + q := &mockQuerier{} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := BuildConvert(q, tc.base) + require.ErrorIs(t, err, tc.wantErr) + }) + } + }) +} + +func TestPrepareConvert_PassFirstTime(t *testing.T) { + holderKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + issuerKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + result, err := PrepareConvert(ConvertParams{ + BuildConvertParams: BuildConvertParams{ + Account: testAccount, + IssuanceID: testIssuanceID, + Amount: 1000, + HolderPrivKey: holderKP.PrivKeyHex, + HolderPubKey: holderKP.PubKeyHex, + }, + IssuerPubKey: issuerKP.PubKeyHex, + Sequence: 1, + FirstTime: true, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, transaction.ConfidentialMPTConvertTx, result.TxType()) + + // First time: key and proof must be set. + require.NotNil(t, result.HolderEncryptionKey) + require.Equal(t, holderKP.PubKeyHex, *result.HolderEncryptionKey) + require.NotNil(t, result.ZKProof) + + // Verify the Schnorr proof cryptographically. + ctxHash, err := proof.ConvertContextHash(testAccount, testIssuanceID, uint32(1)) + require.NoError(t, err) + err = proof.VerifyConvertProof(*result.ZKProof, holderKP.PubKeyHex, ctxHash) + require.NoError(t, err) + + // Transaction must validate. + ok, err := result.Validate() + require.NoError(t, err) + require.True(t, ok) +} + +func TestPrepareConvert_PassNotFirstTime(t *testing.T) { + holderKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + issuerKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + result, err := PrepareConvert(ConvertParams{ + BuildConvertParams: BuildConvertParams{ + Account: testAccount, + IssuanceID: testIssuanceID, + Amount: 500, + HolderPrivKey: holderKP.PrivKeyHex, + HolderPubKey: holderKP.PubKeyHex, + }, + IssuerPubKey: issuerKP.PubKeyHex, + Sequence: 2, + FirstTime: false, + }) + require.NoError(t, err) + require.Nil(t, result.HolderEncryptionKey) + require.Nil(t, result.ZKProof) + + ok, err := result.Validate() + require.NoError(t, err) + require.True(t, ok) +} + +func TestPrepareConvert_PassWithAuditor(t *testing.T) { + holderKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + issuerKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + auditorKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + result, err := PrepareConvert(ConvertParams{ + BuildConvertParams: BuildConvertParams{ + Account: testAccount, + IssuanceID: testIssuanceID, + Amount: 100, + HolderPrivKey: holderKP.PrivKeyHex, + HolderPubKey: holderKP.PubKeyHex, + }, + IssuerPubKey: issuerKP.PubKeyHex, + AuditorPubKey: auditorKP.PubKeyHex, + Sequence: 1, + FirstTime: false, + }) + require.NoError(t, err) + require.NotNil(t, result.AuditorEncryptedAmount) + require.Len(t, *result.AuditorEncryptedAmount, 132) + + ok, err := result.Validate() + require.NoError(t, err) + require.True(t, ok) +} + +func TestPrepareConvert_FailValidation(t *testing.T) { + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + base := BuildConvertParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, HolderPrivKey: kp.PrivKeyHex, HolderPubKey: kp.PubKeyHex} + + tests := []struct { + name string + params ConvertParams + wantErr error + }{ + {name: "fail - missing issuer pub key", params: ConvertParams{BuildConvertParams: base}, wantErr: ErrMissingIssuerKey}, + {name: "fail - invalid issuer pub key (wrong length)", params: ConvertParams{BuildConvertParams: base, IssuerPubKey: "aabb"}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid issuer pub key (not hex)", params: ConvertParams{BuildConvertParams: base, IssuerPubKey: strings.Repeat("ZZ", 33)}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid auditor pub key (wrong length)", params: ConvertParams{BuildConvertParams: base, IssuerPubKey: kp.PubKeyHex, AuditorPubKey: "aabb"}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid auditor pub key (not hex)", params: ConvertParams{BuildConvertParams: base, IssuerPubKey: kp.PubKeyHex, AuditorPubKey: strings.Repeat("ZZ", 33)}, wantErr: ErrInvalidPubKey}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := PrepareConvert(tt.params) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestBuildConvert_PassFirstTime(t *testing.T) { + holderKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + issuerKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + issuanceIndex, err := xrplhash.MPTokenIssuance(testIssuanceID) + require.NoError(t, err) + + q := &mockQuerier{ + accountSeq: 5, + entries: map[string]ledgerentries.FlatLedgerObject{ + issuanceIndex: buildIssuanceEntry(issuerKP.PubKeyHex, ""), + }, + } + + result, err := BuildConvert(q, BuildConvertParams{ + Account: testAccount, + IssuanceID: testIssuanceID, + Amount: 1000, + HolderPrivKey: holderKP.PrivKeyHex, + HolderPubKey: holderKP.PubKeyHex, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, uint32(5), result.Sequence) + require.NotNil(t, result.HolderEncryptionKey) + require.NotNil(t, result.ZKProof) +} + +func TestBuildConvert_PassNotFirstTime(t *testing.T) { + holderKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + issuerKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + issuanceIndex, err := xrplhash.MPTokenIssuance(testIssuanceID) + require.NoError(t, err) + mptokenIndex, err := xrplhash.MPToken(testIssuanceID, testAccount) + require.NoError(t, err) + + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + balanceCt, err := elgamal.Encrypt(500, holderKP.PubKeyHex, bf) + require.NoError(t, err) + + q := &mockQuerier{ + accountSeq: 7, + entries: map[string]ledgerentries.FlatLedgerObject{ + issuanceIndex: buildIssuanceEntry(issuerKP.PubKeyHex, ""), + mptokenIndex: buildMPTokenEntry(holderKP.PubKeyHex, balanceCt, 0, ""), + }, + } + + result, err := BuildConvert(q, BuildConvertParams{ + Account: testAccount, + IssuanceID: testIssuanceID, + Amount: 200, + HolderPrivKey: holderKP.PrivKeyHex, + HolderPubKey: holderKP.PubKeyHex, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Nil(t, result.HolderEncryptionKey) + require.Nil(t, result.ZKProof) +} + +func TestBuildConvert_PassWithAuditor(t *testing.T) { + holderKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + issuerKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + auditorKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + issuanceIndex, err := xrplhash.MPTokenIssuance(testIssuanceID) + require.NoError(t, err) + + q := &mockQuerier{ + accountSeq: 3, + entries: map[string]ledgerentries.FlatLedgerObject{ + issuanceIndex: buildIssuanceEntry(issuerKP.PubKeyHex, auditorKP.PubKeyHex), + }, + } + + result, err := BuildConvert(q, BuildConvertParams{ + Account: testAccount, + IssuanceID: testIssuanceID, + Amount: 100, + HolderPrivKey: holderKP.PrivKeyHex, + HolderPubKey: holderKP.PubKeyHex, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.AuditorEncryptedAmount) +} diff --git a/confidential/builder/errors.go b/confidential/builder/errors.go new file mode 100644 index 000000000..11df1ed4c --- /dev/null +++ b/confidential/builder/errors.go @@ -0,0 +1,34 @@ +package builder + +import "errors" + +// Builder validation errors. +var ( + ErrMissingAccount = errors.New("builder: account is required") + ErrMissingIssuanceID = errors.New("builder: issuance ID is required") + ErrMissingHolderKey = errors.New("builder: holder private/public key is required") + ErrMissingIssuerKey = errors.New("builder: issuer private/public key is required") + ErrMissingSenderKey = errors.New("builder: sender private/public key is required") + ErrMissingReceiverKey = errors.New("builder: receiver public key is required") + ErrMissingDestination = errors.New("builder: destination is required") + ErrMissingHolder = errors.New("builder: holder is required") + ErrMissingSenderState = errors.New("builder: current balance and ciphertext are required") + ErrMissingCiphertext = errors.New("builder: issuer ciphertext is required") + ErrSelfSend = errors.New("builder: sender and destination cannot be the same") + ErrSelfClawback = errors.New("builder: issuer and holder cannot be the same") + ErrZeroAmount = errors.New("builder: amount must be greater than zero") + ErrInsufficientBalance = errors.New("builder: amount exceeds current balance") + ErrLedgerQuery = errors.New("builder: ledger query failed") + ErrEncryptionKeyNotSet = errors.New("builder: encryption key not registered on issuance") + ErrReceiverNotOptedIn = errors.New("builder: receiver has no encryption key registered") + ErrMPTokenNotFound = errors.New("builder: MPToken ledger entry not found") + ErrCryptoFailed = errors.New("builder: cryptographic operation failed") + + ErrInvalidAccount = errors.New("builder: invalid account address") + ErrInvalidDestination = errors.New("builder: invalid destination address") + ErrInvalidHolder = errors.New("builder: invalid holder address") + ErrInvalidIssuanceID = errors.New("builder: invalid issuance ID format (expected 48 hex chars)") + ErrInvalidPrivKey = errors.New("builder: invalid private key format (expected 64 hex chars)") + ErrInvalidPubKey = errors.New("builder: invalid public key format (expected 66 hex chars)") + ErrInvalidCiphertext = errors.New("builder: invalid ciphertext format (expected 132 hex chars)") +) diff --git a/confidential/builder/helpers_test.go b/confidential/builder/helpers_test.go new file mode 100644 index 000000000..87be8c086 --- /dev/null +++ b/confidential/builder/helpers_test.go @@ -0,0 +1,101 @@ +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 ( + testIssuanceID = "000004C463C52827307480341E3CB23A0710CC839EB58A0A" + testAccount = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + testDestination = "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP" +) + +// mockQuerier implements LedgerQuerier for testing. +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 + } + return &account.InfoResponse{ + AccountData: ledgerentries.AccountRoot{Sequence: m.accountSeq}, + }, nil +} + +func (m *mockQuerier) GetLedgerEntry(req *ledger.EntryRequest) (*ledger.EntryResponse, error) { + m.queryCalls++ + node, ok := m.entries[req.Index] + if !ok { + return nil, ErrMPTokenNotFound + } + return &ledger.EntryResponse{Node: node}, nil +} + +// buildIssuanceEntry builds a mock MPTokenIssuance flat entry. +func buildIssuanceEntry(issuerKey, auditorKey string) ledgerentries.FlatLedgerObject { + entry := ledgerentries.FlatLedgerObject{ + "IssuerEncryptionKey": issuerKey, + } + if auditorKey != "" { + entry["AuditorEncryptionKey"] = auditorKey + } + return entry +} + +// buildMPTokenEntry builds a mock MPToken flat entry for a holder. +func buildMPTokenEntry(holderKey, balanceCt string, balanceVersion float64, issuerCt string) ledgerentries.FlatLedgerObject { + entry := ledgerentries.FlatLedgerObject{} + if holderKey != "" { + entry["HolderEncryptionKey"] = holderKey + } + if balanceCt != "" { + entry["ConfidentialBalanceSpending"] = balanceCt + } + if balanceVersion != 0 { + entry["ConfidentialBalanceVersion"] = balanceVersion + } + if issuerCt != "" { + entry["IssuerEncryptedBalance"] = issuerCt + } + 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, ""), + }, + } +} diff --git a/confidential/builder/merge_inbox.go b/confidential/builder/merge_inbox.go new file mode 100644 index 000000000..825e4a0c2 --- /dev/null +++ b/confidential/builder/merge_inbox.go @@ -0,0 +1,72 @@ +package builder + +import ( + addresscodec "github.com/Peersyst/xrpl-go/address-codec" + "github.com/Peersyst/xrpl-go/xrpl/transaction" + "github.com/Peersyst/xrpl-go/xrpl/transaction/types" +) + +// BuildMergeInboxParams holds minimal inputs for BuildMergeInbox. +// Sequence is auto-resolved from the ledger. +type BuildMergeInboxParams struct { + Account string + IssuanceID string +} + +// MergeInboxParams holds inputs for PrepareMergeInbox. +type MergeInboxParams struct { + BuildMergeInboxParams + Sequence uint32 +} + +func validateMergeInboxBase(p BuildMergeInboxParams) error { + if p.Account == "" { + return ErrMissingAccount + } + if !addresscodec.IsValidAddress(p.Account) { + return ErrInvalidAccount + } + if p.IssuanceID == "" { + return ErrMissingIssuanceID + } + if !transaction.IsMPTIssuanceID(p.IssuanceID) { + return ErrInvalidIssuanceID + } + return nil +} + +// BuildMergeInbox queries ledger state and builds a ConfidentialMPTMergeInbox transaction. +func BuildMergeInbox(q LedgerQuerier, p BuildMergeInboxParams) (*transaction.ConfidentialMPTMergeInbox, error) { + if err := validateMergeInboxBase(p); err != nil { + return nil, err + } + + seq, err := getSequence(q, p.Account) + if err != nil { + return nil, err + } + + return PrepareMergeInbox(MergeInboxParams{ + BuildMergeInboxParams: p, + Sequence: seq, + }) +} + +// PrepareMergeInbox builds a ConfidentialMPTMergeInbox transaction. +// No cryptographic operations needed; this is a permissionless inbox-to-spending balance merge. +func PrepareMergeInbox(p MergeInboxParams) (*transaction.ConfidentialMPTMergeInbox, error) { + if err := validateMergeInboxBase(p.BuildMergeInboxParams); err != nil { + return nil, err + } + + tx := &transaction.ConfidentialMPTMergeInbox{ + BaseTx: transaction.BaseTx{ + Account: types.Address(p.Account), + TransactionType: transaction.ConfidentialMPTMergeInboxTx, + Sequence: p.Sequence, + }, + MPTokenIssuanceID: p.IssuanceID, + } + + return tx, nil +} diff --git a/confidential/builder/merge_inbox_test.go b/confidential/builder/merge_inbox_test.go new file mode 100644 index 000000000..b7a05cc6c --- /dev/null +++ b/confidential/builder/merge_inbox_test.go @@ -0,0 +1,73 @@ +package builder + +import ( + "strings" + "testing" + + "github.com/Peersyst/xrpl-go/xrpl/transaction" + "github.com/stretchr/testify/require" +) + +// TestMergeInboxBaseValidation verifies all validateMergeInboxBase branches through both entry points. +func TestMergeInboxBaseValidation(t *testing.T) { + cases := []struct { + name string + base BuildMergeInboxParams + wantErr error + }{ + {name: "fail - missing account", base: BuildMergeInboxParams{IssuanceID: testIssuanceID}, wantErr: ErrMissingAccount}, + {name: "fail - invalid account", base: BuildMergeInboxParams{Account: "notanaddress", IssuanceID: testIssuanceID}, wantErr: ErrInvalidAccount}, + {name: "fail - missing issuance ID", base: BuildMergeInboxParams{Account: testAccount}, wantErr: ErrMissingIssuanceID}, + {name: "fail - invalid issuance ID (not hex)", base: BuildMergeInboxParams{Account: testAccount, IssuanceID: strings.Repeat("GG", 24)}, wantErr: ErrInvalidIssuanceID}, + {name: "fail - invalid issuance ID (wrong length)", base: BuildMergeInboxParams{Account: testAccount, IssuanceID: "aabb"}, wantErr: ErrInvalidIssuanceID}, + } + + t.Run("fail - validation PrepareMergeInbox", func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := PrepareMergeInbox(MergeInboxParams{BuildMergeInboxParams: tc.base}) + require.ErrorIs(t, err, tc.wantErr) + }) + } + }) + + t.Run("fail - validation BuildMergeInbox", func(t *testing.T) { + q := &mockQuerier{} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := BuildMergeInbox(q, tc.base) + require.ErrorIs(t, err, tc.wantErr) + }) + } + }) +} + +func TestPrepareMergeInbox_Pass(t *testing.T) { + result, err := PrepareMergeInbox(MergeInboxParams{ + BuildMergeInboxParams: BuildMergeInboxParams{ + Account: testAccount, + IssuanceID: testIssuanceID, + }, + Sequence: 42, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, transaction.ConfidentialMPTMergeInboxTx, result.TxType()) + require.Equal(t, testIssuanceID, result.MPTokenIssuanceID) + + ok, err := result.Validate() + require.NoError(t, err) + require.True(t, ok) +} + +func TestBuildMergeInbox_Pass(t *testing.T) { + q := &mockQuerier{accountSeq: 42} + + result, err := BuildMergeInbox(q, BuildMergeInboxParams{ + Account: testAccount, + IssuanceID: testIssuanceID, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, uint32(42), result.Sequence) +} diff --git a/confidential/builder/querier.go b/confidential/builder/querier.go new file mode 100644 index 000000000..9495f9f1c --- /dev/null +++ b/confidential/builder/querier.go @@ -0,0 +1,106 @@ +package builder + +import ( + "fmt" + + xrplhash "github.com/Peersyst/xrpl-go/xrpl/hash" + "github.com/Peersyst/xrpl-go/xrpl/queries/account" + "github.com/Peersyst/xrpl-go/xrpl/queries/ledger" + "github.com/Peersyst/xrpl-go/xrpl/transaction/types" +) + +// LedgerQuerier is the minimal interface needed to query ledger state. +// Both rpc.Client and websocket.Client satisfy this interface. +type LedgerQuerier interface { + GetAccountInfo(req *account.InfoRequest) (*account.InfoResponse, error) + GetLedgerEntry(req *ledger.EntryRequest) (*ledger.EntryResponse, error) +} + +// getSequence fetches the account sequence number. +func getSequence(q LedgerQuerier, addr string) (uint32, error) { + resp, err := q.GetAccountInfo(&account.InfoRequest{ + Account: types.Address(addr), + }) + if err != nil { + return 0, fmt.Errorf("%w: %w", ErrLedgerQuery, err) + } + return resp.AccountData.Sequence, nil +} + +// getIssuanceKeys fetches IssuerEncryptionKey and AuditorEncryptionKey from an MPTokenIssuance. +// Uses hash.MPTokenIssuance() to compute the ledger entry index. +func getIssuanceKeys(q LedgerQuerier, issuanceID string) (issuerKey, auditorKey string, err error) { + index, err := xrplhash.MPTokenIssuance(issuanceID) + if err != nil { + return "", "", fmt.Errorf("%w: %w", ErrLedgerQuery, err) + } + + resp, err := q.GetLedgerEntry(&ledger.EntryRequest{Index: index}) + if err != nil { + return "", "", fmt.Errorf("%w: %w", ErrLedgerQuery, err) + } + + if v, ok := resp.Node["IssuerEncryptionKey"].(string); ok { + issuerKey = v + } + if issuerKey == "" { + return "", "", ErrEncryptionKeyNotSet + } + if v, ok := resp.Node["AuditorEncryptionKey"].(string); ok { + auditorKey = v + } + return issuerKey, auditorKey, nil +} + +// mpTokenIndex computes the ledger entry index for an MPToken. +func mpTokenIndex(issuanceID, holder string) (string, error) { + index, err := xrplhash.MPToken(issuanceID, holder) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrLedgerQuery, err) + } + return index, nil +} + +// getMPTokenState fetches MPToken fields for a holder. +// Returns holderKey, balanceCt, balanceVersion. Returns ErrMPTokenNotFound if the entry does not exist. +func getMPTokenState(q LedgerQuerier, issuanceID, holder string) (holderKey, balanceCt string, balanceVersion uint32, err error) { + index, err := mpTokenIndex(issuanceID, holder) + if err != nil { + return "", "", 0, err + } + + resp, err := q.GetLedgerEntry(&ledger.EntryRequest{Index: index}) + if err != nil { + return "", "", 0, fmt.Errorf("%w: %w", ErrMPTokenNotFound, err) + } + + if v, ok := resp.Node["HolderEncryptionKey"].(string); ok { + holderKey = v + } + if v, ok := resp.Node["ConfidentialBalanceSpending"].(string); ok { + balanceCt = v + } + if v, ok := resp.Node["ConfidentialBalanceVersion"].(float64); ok { + balanceVersion = uint32(v) + } + return holderKey, balanceCt, balanceVersion, nil +} + +// getIssuerCiphertext fetches the IssuerEncryptedBalance from a holder's MPToken. +func getIssuerCiphertext(q LedgerQuerier, issuanceID, holder string) (string, error) { + index, err := mpTokenIndex(issuanceID, holder) + if err != nil { + return "", err + } + + resp, err := q.GetLedgerEntry(&ledger.EntryRequest{Index: index}) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrLedgerQuery, err) + } + + ct, ok := resp.Node["IssuerEncryptedBalance"].(string) + if !ok || ct == "" { + return "", fmt.Errorf("%w: IssuerEncryptedBalance not found", ErrLedgerQuery) + } + return ct, nil +} diff --git a/confidential/builder/send.go b/confidential/builder/send.go new file mode 100644 index 000000000..a72b20ec2 --- /dev/null +++ b/confidential/builder/send.go @@ -0,0 +1,274 @@ +package builder + +import ( + "fmt" + + addresscodec "github.com/Peersyst/xrpl-go/address-codec" + "github.com/Peersyst/xrpl-go/confidential/commitment" + "github.com/Peersyst/xrpl-go/confidential/elgamal" + "github.com/Peersyst/xrpl-go/confidential/proof" + "github.com/Peersyst/xrpl-go/xrpl/transaction" + "github.com/Peersyst/xrpl-go/xrpl/transaction/types" +) + +// 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 +// 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) + BalanceRange elgamal.AmountRange // Inclusive balance decryption bounds + CredentialIDs []string // Optional +} + +// SendParams holds inputs for PrepareSend. +type SendParams struct { + BuildSendParams + ReceiverPubKey string // 66 hex chars (receiver's registered encryption key) + IssuerPubKey string // 66 hex chars + AuditorPubKey string // 66 hex chars, empty if no auditor + Sequence uint32 + BalanceVersion uint32 // From MPToken.ConfidentialBalanceVersion + CurrentBalance uint64 // Sender's known plaintext spending balance + CurrentBalanceCt string // 132 hex chars, current ConfidentialBalanceSpending ciphertext +} + +func validateSendBase(p BuildSendParams) error { + if p.Account == "" { + return ErrMissingAccount + } + if !addresscodec.IsValidAddress(p.Account) { + return ErrInvalidAccount + } + if p.Destination == "" { + return ErrMissingDestination + } + if !addresscodec.IsValidAddress(p.Destination) { + return ErrInvalidDestination + } + if p.Account == p.Destination { + return ErrSelfSend + } + if p.IssuanceID == "" { + return ErrMissingIssuanceID + } + if !transaction.IsMPTIssuanceID(p.IssuanceID) { + return ErrInvalidIssuanceID + } + if p.Amount == 0 { + return ErrZeroAmount + } + if p.SenderPrivKey == "" { + return ErrMissingSenderKey + } + if !transaction.IsValidPrivKey(p.SenderPrivKey) { + return ErrInvalidPrivKey + } + if p.SenderPubKey == "" { + return ErrMissingSenderKey + } + if !transaction.IsValidCompressedEncryptionKey(p.SenderPubKey) { + return fmt.Errorf("sender pub key: %w", ErrInvalidPubKey) + } + return nil +} + +// BuildSend queries ledger state, decrypts the sender's balance, and builds +// a ConfidentialMPTSend transaction. +func BuildSend(q LedgerQuerier, p BuildSendParams) (*transaction.ConfidentialMPTSend, error) { + 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 { + return nil, err + } + + issuerKey, auditorKey, err := getIssuanceKeys(q, p.IssuanceID) + if err != nil { + return nil, err + } + + senderLedgerKey, senderBalanceCt, balanceVersion, err := getMPTokenState(q, p.IssuanceID, p.Account) + if err != nil { + return nil, err + } + + // Validate sender pubkey matches ledger. + if senderLedgerKey != "" && senderLedgerKey != p.SenderPubKey { + return nil, fmt.Errorf("%w: sender pubkey does not match ledger", ErrCryptoFailed) + } + + currentBalance, err := elgamal.Decrypt(senderBalanceCt, p.SenderPrivKey, p.BalanceRange) + if err != nil { + return nil, fmt.Errorf("%w: failed to decrypt balance: %w", ErrCryptoFailed, err) + } + + receiverKey, _, _, err := getMPTokenState(q, p.IssuanceID, p.Destination) + if err != nil { + return nil, ErrReceiverNotOptedIn + } + if receiverKey == "" { + return nil, ErrReceiverNotOptedIn + } + + return PrepareSend(SendParams{ + BuildSendParams: p, + ReceiverPubKey: receiverKey, + IssuerPubKey: issuerKey, + AuditorPubKey: auditorKey, + Sequence: seq, + BalanceVersion: balanceVersion, + CurrentBalance: currentBalance, + CurrentBalanceCt: senderBalanceCt, + }) +} + +// PrepareSend builds a ConfidentialMPTSend transaction. +// +// Steps: +// 1. Encrypt amount under sender, receiver, issuer (and optionally auditor) keys with shared BF. +// 2. Create Pedersen commitment for the transfer amount. +// 3. Create Pedersen commitment for the current balance (fresh BF). +// 4. Compute send context hash (account, issuance, seq, dest, version). +// 5. Build participant list and proof params. +// 6. Generate composite ZK proof (range + linkage + equality). +func PrepareSend(p SendParams) (*transaction.ConfidentialMPTSend, error) { + if err := validateSendBase(p.BuildSendParams); err != nil { + return nil, err + } + if p.ReceiverPubKey == "" { + return nil, ErrMissingReceiverKey + } + if !transaction.IsValidCompressedEncryptionKey(p.ReceiverPubKey) { + return nil, fmt.Errorf("receiver pub key: %w", ErrInvalidPubKey) + } + if p.IssuerPubKey == "" { + return nil, ErrMissingIssuerKey + } + if !transaction.IsValidCompressedEncryptionKey(p.IssuerPubKey) { + return nil, fmt.Errorf("issuer pub key: %w", ErrInvalidPubKey) + } + if p.AuditorPubKey != "" && !transaction.IsValidCompressedEncryptionKey(p.AuditorPubKey) { + return nil, fmt.Errorf("auditor pub key: %w", ErrInvalidPubKey) + } + if p.CurrentBalanceCt == "" { + return nil, ErrMissingSenderState + } + if !transaction.IsValidCiphertext(p.CurrentBalanceCt) { + return nil, ErrInvalidCiphertext + } + if p.Amount > p.CurrentBalance { + return nil, ErrInsufficientBalance + } + + amountBF, err := elgamal.GenerateBlindingFactor() + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + + senderCt, err := elgamal.Encrypt(p.Amount, p.SenderPubKey, amountBF) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + destCt, err := elgamal.Encrypt(p.Amount, p.ReceiverPubKey, amountBF) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + issuerCt, err := elgamal.Encrypt(p.Amount, p.IssuerPubKey, amountBF) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + + var auditorCt string + if p.AuditorPubKey != "" { + auditorCt, err = elgamal.Encrypt(p.Amount, p.AuditorPubKey, amountBF) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + } + + amountCommit, err := commitment.Create(p.Amount, amountBF) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + + balanceBF, err := elgamal.GenerateBlindingFactor() + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + balanceCommit, err := commitment.Create(p.CurrentBalance, balanceBF) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + + ctxHash, err := proof.SendContextHash(p.Account, p.IssuanceID, p.Sequence, p.Destination, p.BalanceVersion) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + + participants := []proof.Participant{ + {PubKeyHex: p.SenderPubKey, CiphertextHex: senderCt}, + {PubKeyHex: p.ReceiverPubKey, CiphertextHex: destCt}, + {PubKeyHex: p.IssuerPubKey, CiphertextHex: issuerCt}, + } + if p.AuditorPubKey != "" { + participants = append(participants, proof.Participant{ + PubKeyHex: p.AuditorPubKey, + CiphertextHex: auditorCt, + }) + } + + amountParams := proof.Params{ + CommitmentHex: amountCommit, + Amount: p.Amount, + CiphertextHex: senderCt, + BlindingFactorHex: amountBF, + } + balanceParams := proof.Params{ + CommitmentHex: balanceCommit, + Amount: p.CurrentBalance, + CiphertextHex: p.CurrentBalanceCt, + BlindingFactorHex: balanceBF, + } + + proofHex, err := proof.GenerateSendProof(p.SenderPrivKey, p.SenderPubKey, p.Amount, participants, amountBF, ctxHash, amountParams, balanceParams) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCryptoFailed, err) + } + + tx := &transaction.ConfidentialMPTSend{ + BaseTx: transaction.BaseTx{ + Account: types.Address(p.Account), + TransactionType: transaction.ConfidentialMPTSendTx, + Sequence: p.Sequence, + }, + MPTokenIssuanceID: p.IssuanceID, + Destination: types.Address(p.Destination), + SenderEncryptedAmount: senderCt, + DestinationEncryptedAmount: destCt, + IssuerEncryptedAmount: issuerCt, + ZKProof: proofHex, + AmountCommitment: amountCommit, + BalanceCommitment: balanceCommit, + } + + if auditorCt != "" { + tx.AuditorEncryptedAmount = &auditorCt + } + + if len(p.CredentialIDs) > 0 { + tx.CredentialIDs = p.CredentialIDs + } + + return tx, nil +} diff --git a/confidential/builder/send_test.go b/confidential/builder/send_test.go new file mode 100644 index 000000000..72d11891d --- /dev/null +++ b/confidential/builder/send_test.go @@ -0,0 +1,372 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package builder + +import ( + "strings" + "testing" + + "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" +) + +// TestSendBaseValidation verifies all validateSendBase branches through both entry points. +func TestSendBaseValidation(t *testing.T) { + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + cases := []struct { + name string + base BuildSendParams + wantErr error + }{ + {name: "fail - missing account", base: BuildSendParams{Destination: testDestination, IssuanceID: testIssuanceID, Amount: 1, SenderPrivKey: kp.PrivKeyHex, SenderPubKey: kp.PubKeyHex}, wantErr: ErrMissingAccount}, + {name: "fail - invalid account", base: BuildSendParams{Account: "notanaddress", Destination: testDestination, IssuanceID: testIssuanceID, Amount: 1, SenderPrivKey: kp.PrivKeyHex, SenderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidAccount}, + {name: "fail - missing destination", base: BuildSendParams{Account: testAccount, IssuanceID: testIssuanceID, Amount: 1, SenderPrivKey: kp.PrivKeyHex, SenderPubKey: kp.PubKeyHex}, wantErr: ErrMissingDestination}, + {name: "fail - invalid destination", base: BuildSendParams{Account: testAccount, Destination: "notanaddress", IssuanceID: testIssuanceID, Amount: 1, SenderPrivKey: kp.PrivKeyHex, SenderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidDestination}, + {name: "fail - self send", base: BuildSendParams{Account: testAccount, Destination: testAccount, IssuanceID: testIssuanceID, Amount: 1, SenderPrivKey: kp.PrivKeyHex, SenderPubKey: kp.PubKeyHex}, wantErr: ErrSelfSend}, + {name: "fail - missing issuance ID", base: BuildSendParams{Account: testAccount, Destination: testDestination, Amount: 1, SenderPrivKey: kp.PrivKeyHex, SenderPubKey: kp.PubKeyHex}, wantErr: ErrMissingIssuanceID}, + {name: "fail - invalid issuance ID (not hex)", base: BuildSendParams{Account: testAccount, Destination: testDestination, IssuanceID: strings.Repeat("GG", 24), Amount: 1, SenderPrivKey: kp.PrivKeyHex, SenderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidIssuanceID}, + {name: "fail - invalid issuance ID (wrong length)", base: BuildSendParams{Account: testAccount, Destination: testDestination, IssuanceID: "aabb", Amount: 1, SenderPrivKey: kp.PrivKeyHex, SenderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidIssuanceID}, + {name: "fail - zero amount", base: BuildSendParams{Account: testAccount, Destination: testDestination, IssuanceID: testIssuanceID, Amount: 0, SenderPrivKey: kp.PrivKeyHex, SenderPubKey: kp.PubKeyHex}, wantErr: ErrZeroAmount}, + {name: "fail - missing sender priv key", base: BuildSendParams{Account: testAccount, Destination: testDestination, IssuanceID: testIssuanceID, Amount: 1, SenderPubKey: kp.PubKeyHex}, wantErr: ErrMissingSenderKey}, + {name: "fail - invalid sender priv key (not hex)", base: BuildSendParams{Account: testAccount, Destination: testDestination, IssuanceID: testIssuanceID, Amount: 1, SenderPrivKey: strings.Repeat("ZZ", 32), SenderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidPrivKey}, + {name: "fail - invalid sender priv key (wrong length)", base: BuildSendParams{Account: testAccount, Destination: testDestination, IssuanceID: testIssuanceID, Amount: 1, SenderPrivKey: "aabb", SenderPubKey: kp.PubKeyHex}, wantErr: ErrInvalidPrivKey}, + {name: "fail - missing sender pub key", base: BuildSendParams{Account: testAccount, Destination: testDestination, IssuanceID: testIssuanceID, Amount: 1, SenderPrivKey: kp.PrivKeyHex}, wantErr: ErrMissingSenderKey}, + {name: "fail - invalid sender pub key (not hex)", base: BuildSendParams{Account: testAccount, Destination: testDestination, IssuanceID: testIssuanceID, Amount: 1, SenderPrivKey: kp.PrivKeyHex, SenderPubKey: strings.Repeat("ZZ", 33)}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid sender pub key (wrong length)", base: BuildSendParams{Account: testAccount, Destination: testDestination, IssuanceID: testIssuanceID, Amount: 1, SenderPrivKey: kp.PrivKeyHex, SenderPubKey: "aabb"}, wantErr: ErrInvalidPubKey}, + } + + t.Run("fail - validation PrepareSend", func(t *testing.T) { + rkp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + ikp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := PrepareSend(SendParams{ + BuildSendParams: tc.base, + ReceiverPubKey: rkp.PubKeyHex, + IssuerPubKey: ikp.PubKeyHex, + CurrentBalance: 100, + CurrentBalanceCt: "aa", + }) + require.ErrorIs(t, err, tc.wantErr) + }) + } + }) + + t.Run("fail - validation BuildSend", func(t *testing.T) { + q := &mockQuerier{} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := BuildSend(q, tc.base) + require.ErrorIs(t, err, tc.wantErr) + }) + } + }) +} + +func TestPrepareSend_Pass(t *testing.T) { + const currentBalance uint64 = 1000 + const sendAmount uint64 = 500 + + senderKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + receiverKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + issuerKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + // Simulate existing balance state. + balanceBF, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + balanceCt, err := elgamal.Encrypt(currentBalance, senderKP.PubKeyHex, balanceBF) + require.NoError(t, err) + + result, err := PrepareSend(SendParams{ + BuildSendParams: BuildSendParams{ + Account: testAccount, + Destination: testDestination, + IssuanceID: testIssuanceID, + Amount: sendAmount, + SenderPrivKey: senderKP.PrivKeyHex, + SenderPubKey: senderKP.PubKeyHex, + }, + ReceiverPubKey: receiverKP.PubKeyHex, + IssuerPubKey: issuerKP.PubKeyHex, + Sequence: 1, + BalanceVersion: 0, + CurrentBalance: currentBalance, + CurrentBalanceCt: balanceCt, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, transaction.ConfidentialMPTSendTx, result.TxType()) + + // Transaction fields. + require.Len(t, result.SenderEncryptedAmount, 132) + require.Len(t, result.DestinationEncryptedAmount, 132) + require.Len(t, result.IssuerEncryptedAmount, 132) + require.Nil(t, result.AuditorEncryptedAmount) + require.NotEmpty(t, result.ZKProof) + require.Len(t, result.AmountCommitment, 66) + require.Len(t, result.BalanceCommitment, 66) + + // Verify the composite proof cryptographically. + ctxHash, err := proof.SendContextHash(testAccount, testIssuanceID, uint32(1), testDestination, uint32(0)) + require.NoError(t, err) + + participants := []proof.Participant{ + {PubKeyHex: senderKP.PubKeyHex, CiphertextHex: result.SenderEncryptedAmount}, + {PubKeyHex: receiverKP.PubKeyHex, CiphertextHex: result.DestinationEncryptedAmount}, + {PubKeyHex: issuerKP.PubKeyHex, CiphertextHex: result.IssuerEncryptedAmount}, + } + err = proof.VerifySendProof(result.ZKProof, participants, balanceCt, result.AmountCommitment, result.BalanceCommitment, ctxHash) + require.NoError(t, err) + + ok, err := result.Validate() + require.NoError(t, err) + require.True(t, ok) +} + +func TestPrepareSend_PassWithAuditor(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) + auditorKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + balanceBF, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + balanceCt, err := elgamal.Encrypt(1000, senderKP.PubKeyHex, balanceBF) + require.NoError(t, err) + + result, err := PrepareSend(SendParams{ + BuildSendParams: BuildSendParams{ + Account: testAccount, + Destination: testDestination, + IssuanceID: testIssuanceID, + Amount: 500, + SenderPrivKey: senderKP.PrivKeyHex, + SenderPubKey: senderKP.PubKeyHex, + }, + ReceiverPubKey: receiverKP.PubKeyHex, + IssuerPubKey: issuerKP.PubKeyHex, + AuditorPubKey: auditorKP.PubKeyHex, + Sequence: 1, + CurrentBalance: 1000, + CurrentBalanceCt: balanceCt, + }) + require.NoError(t, err) + require.NotNil(t, result.AuditorEncryptedAmount) + require.Len(t, *result.AuditorEncryptedAmount, 132) + + ok, err := result.Validate() + require.NoError(t, err) + require.True(t, ok) +} + +func TestPrepareSend_PassWithCredentialIDs(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) + + balanceBF, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + balanceCt, err := elgamal.Encrypt(1000, senderKP.PubKeyHex, balanceBF) + require.NoError(t, err) + + credIDs := []string{"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2", "B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3"} + result, err := PrepareSend(SendParams{ + BuildSendParams: BuildSendParams{ + Account: testAccount, + Destination: testDestination, + IssuanceID: testIssuanceID, + Amount: 100, + SenderPrivKey: senderKP.PrivKeyHex, + SenderPubKey: senderKP.PubKeyHex, + CredentialIDs: credIDs, + }, + ReceiverPubKey: receiverKP.PubKeyHex, + IssuerPubKey: issuerKP.PubKeyHex, + Sequence: 1, + CurrentBalance: 1000, + CurrentBalanceCt: balanceCt, + }) + require.NoError(t, err) + require.Equal(t, credIDs, []string(result.CredentialIDs)) + + ok, err := result.Validate() + require.NoError(t, err) + require.True(t, ok) +} + +func TestPrepareSend_FailValidation(t *testing.T) { + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + ikp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + rkp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + validCt, err := elgamal.Encrypt(100, kp.PubKeyHex, bf) + require.NoError(t, err) + + validBase := BuildSendParams{ + Account: testAccount, + Destination: testDestination, + IssuanceID: testIssuanceID, + Amount: 1, + SenderPrivKey: kp.PrivKeyHex, + SenderPubKey: kp.PubKeyHex, + } + + tests := []struct { + name string + params SendParams + wantErr error + }{ + {name: "fail - missing receiver key", params: SendParams{BuildSendParams: validBase, IssuerPubKey: ikp.PubKeyHex, CurrentBalanceCt: validCt, CurrentBalance: 100}, wantErr: ErrMissingReceiverKey}, + {name: "fail - invalid receiver pub key (not hex)", params: SendParams{BuildSendParams: validBase, ReceiverPubKey: strings.Repeat("ZZ", 33), IssuerPubKey: ikp.PubKeyHex, CurrentBalanceCt: validCt, CurrentBalance: 100}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid receiver pub key (wrong length)", params: SendParams{BuildSendParams: validBase, ReceiverPubKey: "aabb", IssuerPubKey: ikp.PubKeyHex, CurrentBalanceCt: validCt, CurrentBalance: 100}, wantErr: ErrInvalidPubKey}, + {name: "fail - missing issuer key", params: SendParams{BuildSendParams: validBase, ReceiverPubKey: rkp.PubKeyHex, CurrentBalanceCt: validCt, CurrentBalance: 100}, wantErr: ErrMissingIssuerKey}, + {name: "fail - invalid issuer pub key (not hex)", params: SendParams{BuildSendParams: validBase, ReceiverPubKey: rkp.PubKeyHex, IssuerPubKey: strings.Repeat("ZZ", 33), CurrentBalanceCt: validCt, CurrentBalance: 100}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid issuer pub key (wrong length)", params: SendParams{BuildSendParams: validBase, ReceiverPubKey: rkp.PubKeyHex, IssuerPubKey: "aabb", CurrentBalanceCt: validCt, CurrentBalance: 100}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid auditor pub key (not hex)", params: SendParams{BuildSendParams: validBase, ReceiverPubKey: rkp.PubKeyHex, IssuerPubKey: ikp.PubKeyHex, AuditorPubKey: strings.Repeat("ZZ", 33), CurrentBalanceCt: validCt, CurrentBalance: 100}, wantErr: ErrInvalidPubKey}, + {name: "fail - invalid auditor pub key (wrong length)", params: SendParams{BuildSendParams: validBase, ReceiverPubKey: rkp.PubKeyHex, IssuerPubKey: ikp.PubKeyHex, AuditorPubKey: "aabb", CurrentBalanceCt: validCt, CurrentBalance: 100}, wantErr: ErrInvalidPubKey}, + {name: "fail - missing sender state", params: SendParams{BuildSendParams: validBase, ReceiverPubKey: rkp.PubKeyHex, IssuerPubKey: ikp.PubKeyHex}, wantErr: ErrMissingSenderState}, + {name: "fail - invalid ciphertext (not hex)", params: SendParams{BuildSendParams: validBase, ReceiverPubKey: rkp.PubKeyHex, IssuerPubKey: ikp.PubKeyHex, CurrentBalanceCt: strings.Repeat("ZZ", 66), CurrentBalance: 100}, wantErr: ErrInvalidCiphertext}, + {name: "fail - invalid ciphertext (wrong length)", params: SendParams{BuildSendParams: validBase, ReceiverPubKey: rkp.PubKeyHex, IssuerPubKey: ikp.PubKeyHex, CurrentBalanceCt: "aabb", CurrentBalance: 100}, wantErr: ErrInvalidCiphertext}, + { + name: "fail - insufficient balance", + params: SendParams{ + BuildSendParams: BuildSendParams{Account: testAccount, Destination: testDestination, IssuanceID: testIssuanceID, Amount: 200, SenderPrivKey: kp.PrivKeyHex, SenderPubKey: kp.PubKeyHex}, + ReceiverPubKey: rkp.PubKeyHex, + IssuerPubKey: ikp.PubKeyHex, + CurrentBalance: 100, + CurrentBalanceCt: validCt, + }, + wantErr: ErrInsufficientBalance, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := PrepareSend(tt.params) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestBuildSend_Pass(t *testing.T) { + const currentBalance uint64 = 1000 + const sendAmount uint64 = 300 + + 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.entries[receiverMPTIndex] = buildMPTokenEntry(receiverKP.PubKeyHex, "", 0, "") + + result, err := BuildSend(q, BuildSendParams{ + Account: testAccount, + Destination: testDestination, + IssuanceID: testIssuanceID, + 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) + issuerKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + const currentBalance uint64 = 1000 + + 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) + require.NoError(t, err) + + q := &mockQuerier{ + accountSeq: 1, + entries: map[string]ledgerentries.FlatLedgerObject{ + issuanceIndex: buildIssuanceEntry(issuerKP.PubKeyHex, ""), + senderMPTIndex: buildMPTokenEntry(senderKP.PubKeyHex, senderBalanceCt, 0, ""), + }, + } + + _, err = BuildSend(q, BuildSendParams{ + Account: testAccount, + Destination: testDestination, + IssuanceID: testIssuanceID, + Amount: 100, + SenderPrivKey: senderKP.PrivKeyHex, + SenderPubKey: senderKP.PubKeyHex, + BalanceRange: elgamal.AmountRange{Low: currentBalance, High: currentBalance}, + }) + require.ErrorIs(t, err, ErrReceiverNotOptedIn) +} diff --git a/confidential/commitment/commitment.go b/confidential/commitment/commitment.go new file mode 100644 index 000000000..225895b49 --- /dev/null +++ b/confidential/commitment/commitment.go @@ -0,0 +1,28 @@ +// Package commitment provides a hex-string API for Pedersen commitment creation. +// It wraps the byte-array function in mptcrypto with hex encoding/decoding. +package commitment + +import ( + "encoding/hex" + "fmt" + + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/Peersyst/xrpl-go/pkg/hexutil" +) + +// Create computes a Pedersen commitment for the given amount and blinding factor. +// bfHex must be 64 hex chars (32 bytes). Returns 66 hex chars (33-byte compressed point). +func Create(amount uint64, bfHex string) (string, error) { + bfBytes, err := hexutil.DecodeFixedHex(bfHex, mptcrypto.BlindingFactorSize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidBlindingFactor, err) + } + + bf := mptcrypto.BlindingFactor(bfBytes) + + c, err := mptcrypto.PedersenCommitment(amount, bf) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrCommitmentFailed, err) + } + return hex.EncodeToString(c[:]), nil +} diff --git a/confidential/commitment/commitment_test.go b/confidential/commitment/commitment_test.go new file mode 100644 index 000000000..ca0bc7a1f --- /dev/null +++ b/confidential/commitment/commitment_test.go @@ -0,0 +1,95 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package commitment_test + +import ( + "math" + "strings" + "testing" + + "github.com/Peersyst/xrpl-go/confidential/commitment" + "github.com/Peersyst/xrpl-go/confidential/elgamal" + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/stretchr/testify/require" +) + +func TestCreate(t *testing.T) { + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + + tests := []struct { + name string + amount uint64 + }{ + {"pass - zero amount", 0}, + {"pass - small amount", 42}, + {"pass - one million", 1_000_000}, + {"pass - max uint64", math.MaxUint64}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := commitment.Create(tt.amount, bf) + require.NoError(t, err) + require.Len(t, c, mptcrypto.CommitmentSize*2) + + prefix := c[:2] + require.Contains(t, []string{"02", "03"}, prefix, "commitment prefix: got %q", prefix) + }) + } +} + +func TestCreateDeterministic(t *testing.T) { + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + + c1, err := commitment.Create(100, bf) + require.NoError(t, err) + + c2, err := commitment.Create(100, bf) + require.NoError(t, err) + + require.Equal(t, c1, c2, "same inputs should produce the same commitment") +} + +func TestCreateDifferentInputs(t *testing.T) { + bf1, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + bf2, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + + t.Run("pass - different amounts, same blinding factor", func(t *testing.T) { + c1, err := commitment.Create(100, bf1) + require.NoError(t, err) + c2, err := commitment.Create(200, bf1) + require.NoError(t, err) + require.NotEqual(t, c1, c2) + }) + + t.Run("pass - same amount, different blinding factors", func(t *testing.T) { + c1, err := commitment.Create(100, bf1) + require.NoError(t, err) + c2, err := commitment.Create(100, bf2) + require.NoError(t, err) + require.NotEqual(t, c1, c2) + }) +} + +func TestCreateInvalidBlindingFactor(t *testing.T) { + tests := []struct { + name string + bf string + }{ + {"fail - bad hex", "not-valid-hex"}, + {"fail - too short", "0102"}, + {"fail - empty", ""}, + {"fail - too long", strings.Repeat("ab", 33)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := commitment.Create(42, tt.bf) + require.ErrorIs(t, err, commitment.ErrInvalidBlindingFactor) + }) + } +} diff --git a/confidential/commitment/errors.go b/confidential/commitment/errors.go new file mode 100644 index 000000000..3cb62b8cf --- /dev/null +++ b/confidential/commitment/errors.go @@ -0,0 +1,10 @@ +package commitment + +import "errors" + +var ( + // ErrInvalidBlindingFactor is returned when a blinding factor is not valid hex or has an unexpected byte length. + ErrInvalidBlindingFactor = errors.New("commitment: invalid blinding factor") + // ErrCommitmentFailed is returned when the underlying C commitment computation fails. + ErrCommitmentFailed = errors.New("commitment: computation failed") +) diff --git a/confidential/deps/VERSION b/confidential/deps/VERSION new file mode 100644 index 000000000..6d7de6e6a --- /dev/null +++ b/confidential/deps/VERSION @@ -0,0 +1 @@ +1.0.2 diff --git a/confidential/deps/include/mpt_protocol.h b/confidential/deps/include/mpt_protocol.h new file mode 100644 index 000000000..aef83155e --- /dev/null +++ b/confidential/deps/include/mpt_protocol.h @@ -0,0 +1,50 @@ +#ifndef MPT_PROTOCOL_H +#define MPT_PROTOCOL_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// XRPL Transaction Types, the number MUST match rippled's definitions +#define kCONFIDENTIAL_MPT_CONVERT 85 +#define kCONFIDENTIAL_MPT_MERGE_INBOX 86 +#define kCONFIDENTIAL_MPT_CONVERT_BACK 87 +#define kCONFIDENTIAL_MPT_SEND 88 +#define kCONFIDENTIAL_MPT_CLAWBACK 89 + +// General crypto primitive sizes in bytes +#define kMPT_HALF_SHA_SIZE 32 +#define kMPT_PUBKEY_SIZE 33 +#define kMPT_PRIVKEY_SIZE 32 +#define kMPT_BLINDING_FACTOR_SIZE 32 +// secp256k1 scalar (challenge / response / nonce) size in bytes. +// Numerically equal to kMPT_HALF_SHA_SIZE; use this name when the value is a +// scalar in Z_q rather than a hash output. +#define kMPT_SCALAR_SIZE 32 + +// ElGamal & Pedersen primitive sizes in bytes +#define kMPT_ELGAMAL_CIPHER_SIZE 33 +#define kMPT_ELGAMAL_TOTAL_SIZE 66 +#define kMPT_PEDERSEN_COMMIT_SIZE 33 + +// Proof sizes in bytes +#define kMPT_SCHNORR_PROOF_SIZE 64 +#define kMPT_SINGLE_BULLETPROOF_SIZE 688 +#define kMPT_DOUBLE_BULLETPROOF_SIZE 754 + +// Context hash size +#define kMPT_ZKP_CONTEXT_HASH_SIZE 74 + +// Account ID size in bytes +#define kMPT_ACCOUNT_ID_SIZE 20 + +// MPTokenIssuance ID size in bytes +#define kMPT_ISSUANCE_ID_SIZE 24 + +#ifdef __cplusplus +} +#endif + +#endif // MPT_PROTOCOL_H diff --git a/confidential/deps/include/secp256k1.h b/confidential/deps/include/secp256k1.h new file mode 100644 index 000000000..2799d4a1f --- /dev/null +++ b/confidential/deps/include/secp256k1.h @@ -0,0 +1,888 @@ +#ifndef SECP256K1_H +#define SECP256K1_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/** Unless explicitly stated all pointer arguments must not be NULL. + * + * The following rules specify the order of arguments in API calls: + * + * 1. Context pointers go first, followed by output arguments, combined + * output/input arguments, and finally input-only arguments. + * 2. Array lengths always immediately follow the argument whose length + * they describe, even if this violates rule 1. + * 3. Within the OUT/OUTIN/IN groups, pointers to data that is typically generated + * later go first. This means: signatures, public nonces, secret nonces, + * messages, public keys, secret keys, tweaks. + * 4. Arguments that are not data pointers go last, from more complex to less + * complex: function pointers, algorithm names, messages, void pointers, + * counts, flags, booleans. + * 5. Opaque data pointers follow the function pointer they are to be passed to. + */ + +/** Opaque data structure that holds context information + * + * The primary purpose of context objects is to store randomization data for + * enhanced protection against side-channel leakage. This protection is only + * effective if the context is randomized after its creation. See + * secp256k1_context_create for creation of contexts and + * secp256k1_context_randomize for randomization. + * + * A secondary purpose of context objects is to store pointers to callback + * functions that the library will call when certain error states arise. See + * secp256k1_context_set_error_callback as well as + * secp256k1_context_set_illegal_callback for details. Future library versions + * may use context objects for additional purposes. + * + * A constructed context can safely be used from multiple threads + * simultaneously, but API calls that take a non-const pointer to a context + * need exclusive access to it. In particular this is the case for + * secp256k1_context_destroy, secp256k1_context_preallocated_destroy, + * and secp256k1_context_randomize. + * + * Regarding randomization, either do it once at creation time (in which case + * you do not need any locking for the other calls), or use a read-write lock. + */ +typedef struct secp256k1_context_struct secp256k1_context; + +/** Opaque data structure that holds a parsed and valid public key. + * + * The exact representation of data inside is implementation defined and not + * guaranteed to be portable between different platforms or versions. It is + * however guaranteed to be 64 bytes in size, and can be safely copied/moved. + * If you need to convert to a format suitable for storage or transmission, + * use secp256k1_ec_pubkey_serialize and secp256k1_ec_pubkey_parse. To + * compare keys, use secp256k1_ec_pubkey_cmp. + */ +typedef struct secp256k1_pubkey { + unsigned char data[64]; +} secp256k1_pubkey; + +/** Opaque data structure that holds a parsed ECDSA signature. + * + * The exact representation of data inside is implementation defined and not + * guaranteed to be portable between different platforms or versions. It is + * however guaranteed to be 64 bytes in size, and can be safely copied/moved. + * If you need to convert to a format suitable for storage, transmission, or + * comparison, use the secp256k1_ecdsa_signature_serialize_* and + * secp256k1_ecdsa_signature_parse_* functions. + */ +typedef struct secp256k1_ecdsa_signature { + unsigned char data[64]; +} secp256k1_ecdsa_signature; + +/** A pointer to a function to deterministically generate a nonce. + * + * Returns: 1 if a nonce was successfully generated. 0 will cause signing to fail. + * Out: nonce32: pointer to a 32-byte array to be filled by the function. + * In: msg32: the 32-byte message hash being verified (will not be NULL) + * key32: pointer to a 32-byte secret key (will not be NULL) + * algo16: pointer to a 16-byte array describing the signature + * algorithm (will be NULL for ECDSA for compatibility). + * data: Arbitrary data pointer that is passed through. + * attempt: how many iterations we have tried to find a nonce. + * This will almost always be 0, but different attempt values + * are required to result in a different nonce. + * + * Except for test cases, this function should compute some cryptographic hash of + * the message, the algorithm, the key and the attempt. + */ +typedef int (*secp256k1_nonce_function)( + unsigned char *nonce32, + const unsigned char *msg32, + const unsigned char *key32, + const unsigned char *algo16, + void *data, + unsigned int attempt +); + +# if !defined(SECP256K1_GNUC_PREREQ) +# if defined(__GNUC__)&&defined(__GNUC_MINOR__) +# define SECP256K1_GNUC_PREREQ(_maj,_min) \ + ((__GNUC__<<16)+__GNUC_MINOR__>=((_maj)<<16)+(_min)) +# else +# define SECP256K1_GNUC_PREREQ(_maj,_min) 0 +# endif +# endif + +/* When this header is used at build-time the SECP256K1_BUILD define needs to be set + * to correctly setup export attributes and nullness checks. This is normally done + * by secp256k1.c but to guard against this header being included before secp256k1.c + * has had a chance to set the define (e.g. via test harnesses that just includes + * secp256k1.c) we set SECP256K1_NO_BUILD when this header is processed without the + * BUILD define so this condition can be caught. + */ +#ifndef SECP256K1_BUILD +# define SECP256K1_NO_BUILD +#endif + +/* Symbol visibility. */ +#if !defined(SECP256K1_API) && defined(SECP256K1_NO_API_VISIBILITY_ATTRIBUTES) + /* The user has requested that we don't specify visibility attributes in + * the public API. + * + * Since all our non-API declarations use the static qualifier, this means + * that the user can use -fvisibility= to set the visibility of the + * API symbols. For instance, -fvisibility=hidden can be useful *even for + * the API symbols*, e.g., when building a static library which is linked + * into a shared library, and the latter should not re-export the + * libsecp256k1 API. + * + * While visibility is a concept that applies only to shared libraries, + * setting visibility will still make a difference when building a static + * library: the visibility settings will be stored in the static library, + * solely for the potential case that the static library will be linked into + * a shared library. In that case, the stored visibility settings will + * resurface and be honored for the shared library. */ +# define SECP256K1_API extern +#endif +#if !defined(SECP256K1_API) +# if defined(SECP256K1_BUILD) + /* On Windows, assume a shared library only if explicitly requested. + * 1. If using Libtool, it defines DLL_EXPORT automatically. + * 2. In other cases, SECP256K1_DLL_EXPORT must be defined. */ +# if defined(_WIN32) && (defined(SECP256K1_DLL_EXPORT) || defined(DLL_EXPORT)) + /* GCC for Windows (e.g., MinGW) accepts the __declspec syntax for + * MSVC compatibility. A __declspec declaration implies (but is not + * exactly equivalent to) __attribute__ ((visibility("default"))), + * and so we actually want __declspec even on GCC, see "Microsoft + * Windows Function Attributes" in the GCC manual and the + * recommendations in https://gcc.gnu.org/wiki/Visibility . */ +# define SECP256K1_API extern __declspec(dllexport) + /* Avoid __attribute__ ((visibility("default"))) on Windows to get rid + * of warnings when compiling with -flto due to a bug in GCC, see + * https://gcc.gnu.org/bugzilla/show_bug.cgi?id=116478 . */ +# elif !defined(_WIN32) && defined (__GNUC__) && (__GNUC__ >= 4) +# define SECP256K1_API extern __attribute__ ((visibility("default"))) +# else +# define SECP256K1_API extern +# endif +# else + /* On Windows, SECP256K1_STATIC must be defined when consuming + * libsecp256k1 as a static library. Note that SECP256K1_STATIC is a + * "consumer-only" macro, and it has no meaning when building + * libsecp256k1. */ +# if defined(_WIN32) && !defined(SECP256K1_STATIC) +# define SECP256K1_API extern __declspec(dllimport) +# else +# define SECP256K1_API extern +# endif +# endif +#endif + +/* Warning attributes + * NONNULL is not used if SECP256K1_BUILD is set to avoid the compiler optimizing out + * some paranoid null checks. */ +# if defined(__GNUC__) && SECP256K1_GNUC_PREREQ(3, 4) +# define SECP256K1_WARN_UNUSED_RESULT __attribute__ ((__warn_unused_result__)) +# else +# define SECP256K1_WARN_UNUSED_RESULT +# endif +# if !defined(SECP256K1_BUILD) && defined(__GNUC__) && SECP256K1_GNUC_PREREQ(3, 4) +# define SECP256K1_ARG_NONNULL(_x) __attribute__ ((__nonnull__(_x))) +# else +# define SECP256K1_ARG_NONNULL(_x) +# endif + +/* Attribute for marking functions, types, and variables as deprecated */ +#if !defined(SECP256K1_BUILD) && defined(__has_attribute) +# if __has_attribute(__deprecated__) +# define SECP256K1_DEPRECATED(_msg) __attribute__ ((__deprecated__(_msg))) +# else +# define SECP256K1_DEPRECATED(_msg) +# endif +#else +# define SECP256K1_DEPRECATED(_msg) +#endif + +/* All flags' lower 8 bits indicate what they're for. Do not use directly. */ +#define SECP256K1_FLAGS_TYPE_MASK ((1 << 8) - 1) +#define SECP256K1_FLAGS_TYPE_CONTEXT (1 << 0) +#define SECP256K1_FLAGS_TYPE_COMPRESSION (1 << 1) +/* The higher bits contain the actual data. Do not use directly. */ +#define SECP256K1_FLAGS_BIT_CONTEXT_VERIFY (1 << 8) +#define SECP256K1_FLAGS_BIT_CONTEXT_SIGN (1 << 9) +#define SECP256K1_FLAGS_BIT_CONTEXT_DECLASSIFY (1 << 10) +#define SECP256K1_FLAGS_BIT_COMPRESSION (1 << 8) + +/** Context flags to pass to secp256k1_context_create, secp256k1_context_preallocated_size, and + * secp256k1_context_preallocated_create. */ +#define SECP256K1_CONTEXT_NONE (SECP256K1_FLAGS_TYPE_CONTEXT) + +/** Deprecated context flags. These flags are treated equivalent to SECP256K1_CONTEXT_NONE. */ +#define SECP256K1_CONTEXT_VERIFY (SECP256K1_FLAGS_TYPE_CONTEXT | SECP256K1_FLAGS_BIT_CONTEXT_VERIFY) +#define SECP256K1_CONTEXT_SIGN (SECP256K1_FLAGS_TYPE_CONTEXT | SECP256K1_FLAGS_BIT_CONTEXT_SIGN) + +/* Testing flag. Do not use. */ +#define SECP256K1_CONTEXT_DECLASSIFY (SECP256K1_FLAGS_TYPE_CONTEXT | SECP256K1_FLAGS_BIT_CONTEXT_DECLASSIFY) + +/** Flag to pass to secp256k1_ec_pubkey_serialize. */ +#define SECP256K1_EC_COMPRESSED (SECP256K1_FLAGS_TYPE_COMPRESSION | SECP256K1_FLAGS_BIT_COMPRESSION) +#define SECP256K1_EC_UNCOMPRESSED (SECP256K1_FLAGS_TYPE_COMPRESSION) + +/** Prefix byte used to tag various encoded curvepoints for specific purposes */ +#define SECP256K1_TAG_PUBKEY_EVEN 0x02 +#define SECP256K1_TAG_PUBKEY_ODD 0x03 +#define SECP256K1_TAG_PUBKEY_UNCOMPRESSED 0x04 +#define SECP256K1_TAG_PUBKEY_HYBRID_EVEN 0x06 +#define SECP256K1_TAG_PUBKEY_HYBRID_ODD 0x07 + +/** A built-in constant secp256k1 context object with static storage duration, to be + * used in conjunction with secp256k1_selftest. + * + * This context object offers *only limited functionality* , i.e., it cannot be used + * for API functions that perform computations involving secret keys, e.g., signing + * and public key generation. If this restriction applies to a specific API function, + * it is mentioned in its documentation. See secp256k1_context_create if you need a + * full context object that supports all functionality offered by the library. + * + * It is highly recommended to call secp256k1_selftest before using this context. + */ +SECP256K1_API const secp256k1_context * const secp256k1_context_static; + +/** Deprecated alias for secp256k1_context_static. */ +SECP256K1_API const secp256k1_context * const secp256k1_context_no_precomp +SECP256K1_DEPRECATED("Use secp256k1_context_static instead"); + +/** Perform basic self tests (to be used in conjunction with secp256k1_context_static) + * + * This function performs self tests that detect some serious usage errors and + * similar conditions, e.g., when the library is compiled for the wrong endianness. + * This is a last resort measure to be used in production. The performed tests are + * very rudimentary and are not intended as a replacement for running the test + * binaries. + * + * It is highly recommended to call this before using secp256k1_context_static. + * It is not necessary to call this function before using a context created with + * secp256k1_context_create (or secp256k1_context_preallocated_create), which will + * take care of performing the self tests. + * + * If the tests fail, this function will call the default error callback to abort the + * program (see secp256k1_context_set_error_callback). + */ +SECP256K1_API void secp256k1_selftest(void); + + +/** Create a secp256k1 context object (in dynamically allocated memory). + * + * This function uses malloc to allocate memory. It is guaranteed that malloc is + * called at most once for every call of this function. If you need to avoid dynamic + * memory allocation entirely, see secp256k1_context_static and the functions in + * secp256k1_preallocated.h. + * + * Returns: pointer to a newly created context object. + * In: flags: Always set to SECP256K1_CONTEXT_NONE (see below). + * + * The only valid non-deprecated flag in recent library versions is + * SECP256K1_CONTEXT_NONE, which will create a context sufficient for all functionality + * offered by the library. All other (deprecated) flags will be treated as equivalent + * to the SECP256K1_CONTEXT_NONE flag. Though the flags parameter primarily exists for + * historical reasons, future versions of the library may introduce new flags. + * + * If the context is intended to be used for API functions that perform computations + * involving secret keys, e.g., signing and public key generation, then it is highly + * recommended to call secp256k1_context_randomize on the context before calling + * those API functions. This will provide enhanced protection against side-channel + * leakage, see secp256k1_context_randomize for details. + * + * Do not create a new context object for each operation, as construction and + * randomization can take non-negligible time. + */ +SECP256K1_API secp256k1_context *secp256k1_context_create( + unsigned int flags +) SECP256K1_WARN_UNUSED_RESULT; + +/** Copy a secp256k1 context object (into dynamically allocated memory). + * + * This function uses malloc to allocate memory. It is guaranteed that malloc is + * called at most once for every call of this function. If you need to avoid dynamic + * memory allocation entirely, see the functions in secp256k1_preallocated.h. + * + * Cloning secp256k1_context_static is not possible, and should not be emulated by + * the caller (e.g., using memcpy). Create a new context instead. + * + * Returns: pointer to a newly created context object. + * Args: ctx: pointer to a context to copy (not secp256k1_context_static). + */ +SECP256K1_API secp256k1_context *secp256k1_context_clone( + const secp256k1_context *ctx +) SECP256K1_ARG_NONNULL(1) SECP256K1_WARN_UNUSED_RESULT; + +/** Destroy a secp256k1 context object (created in dynamically allocated memory). + * + * The context pointer may not be used afterwards. + * + * The context to destroy must have been created using secp256k1_context_create + * or secp256k1_context_clone. If the context has instead been created using + * secp256k1_context_preallocated_create or secp256k1_context_preallocated_clone, the + * behaviour is undefined. In that case, secp256k1_context_preallocated_destroy must + * be used instead. + * + * Args: ctx: pointer to a context to destroy, constructed using + * secp256k1_context_create or secp256k1_context_clone + * (i.e., not secp256k1_context_static). + */ +SECP256K1_API void secp256k1_context_destroy( + secp256k1_context *ctx +) SECP256K1_ARG_NONNULL(1); + +/** Set a callback function to be called when an illegal argument is passed to + * an API call. It will only trigger for violations that are mentioned + * explicitly in the header. + * + * The philosophy is that these shouldn't be dealt with through a specific + * return value, as calling code should not have branches to deal with the case + * that this code itself is broken. + * + * On the other hand, during debug stage, one would want to be informed about + * such mistakes, and the default (crashing) may be inadvisable. Should this + * callback return instead of crashing, the return value and output arguments + * of the API function call are undefined. Moreover, the same API call may + * trigger the callback again in this case. + * + * When this function has not been called (or called with fun==NULL), then the + * default callback will be used. The library provides a default callback which + * writes the message to stderr and calls abort. This default callback can be + * replaced at link time if the preprocessor macro + * USE_EXTERNAL_DEFAULT_CALLBACKS is defined, which is the case if the build + * has been configured with --enable-external-default-callbacks (GNU Autotools) or + * -DSECP256K1_USE_EXTERNAL_DEFAULT_CALLBACKS=ON (CMake). Then the + * following two symbols must be provided to link against: + * - void secp256k1_default_illegal_callback_fn(const char *message, void *data); + * - void secp256k1_default_error_callback_fn(const char *message, void *data); + * The library may call a default callback even before a proper callback data + * pointer could have been set using secp256k1_context_set_illegal_callback or + * secp256k1_context_set_error_callback, e.g., when the creation of a context + * fails. In this case, the corresponding default callback will be called with + * the data pointer argument set to NULL. + * + * Args: ctx: pointer to a context object. + * In: fun: pointer to a function to call when an illegal argument is + * passed to the API, taking a message and an opaque pointer. + * (NULL restores the default callback.) + * data: the opaque pointer to pass to fun above, must be NULL for the + * default callback. + * + * See also secp256k1_context_set_error_callback. + */ +SECP256K1_API void secp256k1_context_set_illegal_callback( + secp256k1_context *ctx, + void (*fun)(const char *message, void *data), + const void *data +) SECP256K1_ARG_NONNULL(1); + +/** Set a callback function to be called when an internal consistency check + * fails. + * + * The default callback writes an error message to stderr and calls abort + * to abort the program. + * + * This can only trigger in case of a hardware failure, miscompilation, + * memory corruption, serious bug in the library, or other error that would + * result in undefined behaviour. It will not trigger due to mere + * incorrect usage of the API (see secp256k1_context_set_illegal_callback + * for that). After this callback returns, anything may happen, including + * crashing. + * + * Args: ctx: pointer to a context object. + * In: fun: pointer to a function to call when an internal error occurs, + * taking a message and an opaque pointer (NULL restores the + * default callback, see secp256k1_context_set_illegal_callback + * for details). + * data: the opaque pointer to pass to fun above, must be NULL for the + * default callback. + * + * See also secp256k1_context_set_illegal_callback. + */ +SECP256K1_API void secp256k1_context_set_error_callback( + secp256k1_context *ctx, + void (*fun)(const char *message, void *data), + const void *data +) SECP256K1_ARG_NONNULL(1); + +/** Parse a variable-length public key into the pubkey object. + * + * Returns: 1 if the public key was fully valid. + * 0 if the public key could not be parsed or is invalid. + * Args: ctx: pointer to a context object. + * Out: pubkey: pointer to a pubkey object. If 1 is returned, it is set to a + * parsed version of input. If not, its value is undefined. + * In: input: pointer to a serialized public key + * inputlen: length of the array pointed to by input + * + * This function supports parsing compressed (33 bytes, header byte 0x02 or + * 0x03), uncompressed (65 bytes, header byte 0x04), or hybrid (65 bytes, header + * byte 0x06 or 0x07) format public keys. + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_ec_pubkey_parse( + const secp256k1_context *ctx, + secp256k1_pubkey *pubkey, + const unsigned char *input, + size_t inputlen +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3); + +/** Serialize a pubkey object into a serialized byte sequence. + * + * Returns: 1 always. + * Args: ctx: pointer to a context object. + * Out: output: pointer to a 65-byte (if compressed==0) or 33-byte (if + * compressed==1) byte array to place the serialized key + * in. + * In/Out: outputlen: pointer to an integer which is initially set to the + * size of output, and is overwritten with the written + * size. + * In: pubkey: pointer to a secp256k1_pubkey containing an + * initialized public key. + * flags: SECP256K1_EC_COMPRESSED if serialization should be in + * compressed format, otherwise SECP256K1_EC_UNCOMPRESSED. + */ +SECP256K1_API int secp256k1_ec_pubkey_serialize( + const secp256k1_context *ctx, + unsigned char *output, + size_t *outputlen, + const secp256k1_pubkey *pubkey, + unsigned int flags +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3) SECP256K1_ARG_NONNULL(4); + +/** Compare two public keys using lexicographic (of compressed serialization) order + * + * Returns: <0 if the first public key is less than the second + * >0 if the first public key is greater than the second + * 0 if the two public keys are equal + * Args: ctx: pointer to a context object + * In: pubkey1: first public key to compare + * pubkey2: second public key to compare + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_ec_pubkey_cmp( + const secp256k1_context *ctx, + const secp256k1_pubkey *pubkey1, + const secp256k1_pubkey *pubkey2 +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3); + +/** Sort public keys using lexicographic (of compressed serialization) order + * + * Returns: 0 if the arguments are invalid. 1 otherwise. + * + * Args: ctx: pointer to a context object + * In: pubkeys: array of pointers to pubkeys to sort + * n_pubkeys: number of elements in the pubkeys array + */ +SECP256K1_API int secp256k1_ec_pubkey_sort( + const secp256k1_context *ctx, + const secp256k1_pubkey **pubkeys, + size_t n_pubkeys +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2); + +/** Parse an ECDSA signature in compact (64 bytes) format. + * + * Returns: 1 when the signature could be parsed, 0 otherwise. + * Args: ctx: pointer to a context object + * Out: sig: pointer to a signature object + * In: input64: pointer to the 64-byte array to parse + * + * The signature must consist of a 32-byte big endian R value, followed by a + * 32-byte big endian S value. If R or S fall outside of [0..order-1], the + * encoding is invalid. R and S with value 0 are allowed in the encoding. + * + * After the call, sig will always be initialized. If parsing failed or R or + * S are zero, the resulting sig value is guaranteed to fail verification for + * any message and public key. + */ +SECP256K1_API int secp256k1_ecdsa_signature_parse_compact( + const secp256k1_context *ctx, + secp256k1_ecdsa_signature *sig, + const unsigned char *input64 +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3); + +/** Parse a DER ECDSA signature. + * + * Returns: 1 when the signature could be parsed, 0 otherwise. + * Args: ctx: pointer to a context object + * Out: sig: pointer to a signature object + * In: input: pointer to the signature to be parsed + * inputlen: the length of the array pointed to be input + * + * This function will accept any valid DER encoded signature, even if the + * encoded numbers are out of range. + * + * After the call, sig will always be initialized. If parsing failed or the + * encoded numbers are out of range, signature verification with it is + * guaranteed to fail for every message and public key. + */ +SECP256K1_API int secp256k1_ecdsa_signature_parse_der( + const secp256k1_context *ctx, + secp256k1_ecdsa_signature *sig, + const unsigned char *input, + size_t inputlen +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3); + +/** Serialize an ECDSA signature in DER format. + * + * Returns: 1 if enough space was available to serialize, 0 otherwise + * Args: ctx: pointer to a context object + * Out: output: pointer to an array to store the DER serialization + * In/Out: outputlen: pointer to a length integer. Initially, this integer + * should be set to the length of output. After the call + * it will be set to the length of the serialization (even + * if 0 was returned). + * In: sig: pointer to an initialized signature object + */ +SECP256K1_API int secp256k1_ecdsa_signature_serialize_der( + const secp256k1_context *ctx, + unsigned char *output, + size_t *outputlen, + const secp256k1_ecdsa_signature *sig +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3) SECP256K1_ARG_NONNULL(4); + +/** Serialize an ECDSA signature in compact (64 byte) format. + * + * Returns: 1 + * Args: ctx: pointer to a context object + * Out: output64: pointer to a 64-byte array to store the compact serialization + * In: sig: pointer to an initialized signature object + * + * See secp256k1_ecdsa_signature_parse_compact for details about the encoding. + */ +SECP256K1_API int secp256k1_ecdsa_signature_serialize_compact( + const secp256k1_context *ctx, + unsigned char *output64, + const secp256k1_ecdsa_signature *sig +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3); + +/** Verify an ECDSA signature. + * + * Returns: 1: correct signature + * 0: incorrect or unparseable signature + * Args: ctx: pointer to a context object + * In: sig: the signature being verified. + * msghash32: the 32-byte message hash being verified. + * The verifier must make sure to apply a cryptographic + * hash function to the message by itself and not accept an + * msghash32 value directly. Otherwise, it would be easy to + * create a "valid" signature without knowledge of the + * secret key. See also + * https://bitcoin.stackexchange.com/a/81116/35586 for more + * background on this topic. + * pubkey: pointer to an initialized public key to verify with. + * + * To avoid accepting malleable signatures, only ECDSA signatures in lower-S + * form are accepted. + * + * If you need to accept ECDSA signatures from sources that do not obey this + * rule, apply secp256k1_ecdsa_signature_normalize to the signature prior to + * verification, but be aware that doing so results in malleable signatures. + * + * For details, see the comments for that function. + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_ecdsa_verify( + const secp256k1_context *ctx, + const secp256k1_ecdsa_signature *sig, + const unsigned char *msghash32, + const secp256k1_pubkey *pubkey +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3) SECP256K1_ARG_NONNULL(4); + +/** Convert a signature to a normalized lower-S form. + * + * Returns: 1 if sigin was not normalized, 0 if it already was. + * Args: ctx: pointer to a context object + * Out: sigout: pointer to a signature to fill with the normalized form, + * or copy if the input was already normalized. (can be NULL if + * you're only interested in whether the input was already + * normalized). + * In: sigin: pointer to a signature to check/normalize (can be identical to sigout) + * + * With ECDSA a third-party can forge a second distinct signature of the same + * message, given a single initial signature, but without knowing the key. This + * is done by negating the S value modulo the order of the curve, 'flipping' + * the sign of the random point R which is not included in the signature. + * + * Forgery of the same message isn't universally problematic, but in systems + * where message malleability or uniqueness of signatures is important this can + * cause issues. This forgery can be blocked by all verifiers forcing signers + * to use a normalized form. + * + * The lower-S form reduces the size of signatures slightly on average when + * variable length encodings (such as DER) are used and is cheap to verify, + * making it a good choice. Security of always using lower-S is assured because + * anyone can trivially modify a signature after the fact to enforce this + * property anyway. + * + * The lower S value is always between 0x1 and + * 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0, + * inclusive. + * + * No other forms of ECDSA malleability are known and none seem likely, but + * there is no formal proof that ECDSA, even with this additional restriction, + * is free of other malleability. Commonly used serialization schemes will also + * accept various non-unique encodings, so care should be taken when this + * property is required for an application. + * + * The secp256k1_ecdsa_sign function will by default create signatures in the + * lower-S form, and secp256k1_ecdsa_verify will not accept others. In case + * signatures come from a system that cannot enforce this property, + * secp256k1_ecdsa_signature_normalize must be called before verification. + */ +SECP256K1_API int secp256k1_ecdsa_signature_normalize( + const secp256k1_context *ctx, + secp256k1_ecdsa_signature *sigout, + const secp256k1_ecdsa_signature *sigin +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(3); + +/** An implementation of RFC6979 (using HMAC-SHA256) as nonce generation function. + * If a data pointer is passed, it is assumed to be a pointer to 32 bytes of + * extra entropy. + */ +SECP256K1_API const secp256k1_nonce_function secp256k1_nonce_function_rfc6979; + +/** A default safe nonce generation function (currently equal to secp256k1_nonce_function_rfc6979). */ +SECP256K1_API const secp256k1_nonce_function secp256k1_nonce_function_default; + +/** Create an ECDSA signature. + * + * Returns: 1: signature created + * 0: the nonce generation function failed, or the secret key was invalid. + * Args: ctx: pointer to a context object (not secp256k1_context_static). + * Out: sig: pointer to an array where the signature will be placed. + * In: msghash32: the 32-byte message hash being signed. + * seckey: pointer to a 32-byte secret key. + * noncefp: pointer to a nonce generation function. If NULL, + * secp256k1_nonce_function_default is used. + * ndata: pointer to arbitrary data used by the nonce generation function + * (can be NULL). If it is non-NULL and + * secp256k1_nonce_function_default is used, then ndata must be a + * pointer to 32-bytes of additional data. + * + * The created signature is always in lower-S form. See + * secp256k1_ecdsa_signature_normalize for more details. + */ +SECP256K1_API int secp256k1_ecdsa_sign( + const secp256k1_context *ctx, + secp256k1_ecdsa_signature *sig, + const unsigned char *msghash32, + const unsigned char *seckey, + secp256k1_nonce_function noncefp, + const void *ndata +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3) SECP256K1_ARG_NONNULL(4); + +/** Verify an elliptic curve secret key. + * + * A secret key is valid if it is not 0 and less than the secp256k1 curve order + * when interpreted as an integer (most significant byte first). The + * probability of choosing a 32-byte string uniformly at random which is an + * invalid secret key is negligible. However, if it does happen it should + * be assumed that the randomness source is severely broken and there should + * be no retry. + * + * Returns: 1: secret key is valid + * 0: secret key is invalid + * Args: ctx: pointer to a context object. + * In: seckey: pointer to a 32-byte secret key. + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_ec_seckey_verify( + const secp256k1_context *ctx, + const unsigned char *seckey +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2); + +/** Compute the public key for a secret key. + * + * Returns: 1: secret was valid, public key stores. + * 0: secret was invalid, try again. + * Args: ctx: pointer to a context object (not secp256k1_context_static). + * Out: pubkey: pointer to the created public key. + * In: seckey: pointer to a 32-byte secret key. + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_ec_pubkey_create( + const secp256k1_context *ctx, + secp256k1_pubkey *pubkey, + const unsigned char *seckey +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3); + +/** Negates a secret key in place. + * + * Returns: 0 if the given secret key is invalid according to + * secp256k1_ec_seckey_verify. 1 otherwise + * Args: ctx: pointer to a context object + * In/Out: seckey: pointer to the 32-byte secret key to be negated. If the + * secret key is invalid according to + * secp256k1_ec_seckey_verify, this function returns 0 and + * seckey will be set to some unspecified value. + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_ec_seckey_negate( + const secp256k1_context *ctx, + unsigned char *seckey +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2); + +/** Negates a public key in place. + * + * Returns: 1 always + * Args: ctx: pointer to a context object + * In/Out: pubkey: pointer to the public key to be negated. + */ +SECP256K1_API int secp256k1_ec_pubkey_negate( + const secp256k1_context *ctx, + secp256k1_pubkey *pubkey +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2); + +/** Tweak a secret key by adding tweak to it. + * + * Returns: 0 if the arguments are invalid or the resulting secret key would be + * invalid (only when the tweak is the negation of the secret key). 1 + * otherwise. + * Args: ctx: pointer to a context object. + * In/Out: seckey: pointer to a 32-byte secret key. If the secret key is + * invalid according to secp256k1_ec_seckey_verify, this + * function returns 0. seckey will be set to some unspecified + * value if this function returns 0. + * In: tweak32: pointer to a 32-byte tweak, which must be valid according to + * secp256k1_ec_seckey_verify or 32 zero bytes. For uniformly + * random 32-byte tweaks, the chance of being invalid is + * negligible (around 1 in 2^128). + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_ec_seckey_tweak_add( + const secp256k1_context *ctx, + unsigned char *seckey, + const unsigned char *tweak32 +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3); + +/** Tweak a public key by adding tweak times the generator to it. + * + * Returns: 0 if the arguments are invalid or the resulting public key would be + * invalid (only when the tweak is the negation of the corresponding + * secret key). 1 otherwise. + * Args: ctx: pointer to a context object. + * In/Out: pubkey: pointer to a public key object. pubkey will be set to an + * invalid value if this function returns 0. + * In: tweak32: pointer to a 32-byte tweak, which must be valid according to + * secp256k1_ec_seckey_verify or 32 zero bytes. For uniformly + * random 32-byte tweaks, the chance of being invalid is + * negligible (around 1 in 2^128). + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_ec_pubkey_tweak_add( + const secp256k1_context *ctx, + secp256k1_pubkey *pubkey, + const unsigned char *tweak32 +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3); + +/** Tweak a secret key by multiplying it by a tweak. + * + * Returns: 0 if the arguments are invalid. 1 otherwise. + * Args: ctx: pointer to a context object. + * In/Out: seckey: pointer to a 32-byte secret key. If the secret key is + * invalid according to secp256k1_ec_seckey_verify, this + * function returns 0. seckey will be set to some unspecified + * value if this function returns 0. + * In: tweak32: pointer to a 32-byte tweak. If the tweak is invalid according to + * secp256k1_ec_seckey_verify, this function returns 0. For + * uniformly random 32-byte arrays the chance of being invalid + * is negligible (around 1 in 2^128). + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_ec_seckey_tweak_mul( + const secp256k1_context *ctx, + unsigned char *seckey, + const unsigned char *tweak32 +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3); + +/** Tweak a public key by multiplying it by a tweak value. + * + * Returns: 0 if the arguments are invalid. 1 otherwise. + * Args: ctx: pointer to a context object. + * In/Out: pubkey: pointer to a public key object. pubkey will be set to an + * invalid value if this function returns 0. + * In: tweak32: pointer to a 32-byte tweak. If the tweak is invalid according to + * secp256k1_ec_seckey_verify, this function returns 0. For + * uniformly random 32-byte arrays the chance of being invalid + * is negligible (around 1 in 2^128). + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_ec_pubkey_tweak_mul( + const secp256k1_context *ctx, + secp256k1_pubkey *pubkey, + const unsigned char *tweak32 +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3); + +/** Randomizes the context to provide enhanced protection against side-channel leakage. + * + * Returns: 1: randomization successful + * 0: error + * Args: ctx: pointer to a context object (not secp256k1_context_static). + * In: seed32: pointer to a 32-byte random seed (NULL resets to initial state). + * + * While secp256k1 code is written and tested to be constant-time no matter what + * secret values are, it is possible that a compiler may output code which is not, + * and also that the CPU may not emit the same radio frequencies or draw the same + * amount of power for all values. Randomization of the context shields against + * side-channel observations which aim to exploit secret-dependent behaviour in + * certain computations which involve secret keys. + * + * It is highly recommended to call this function on contexts returned from + * secp256k1_context_create or secp256k1_context_clone (or from the corresponding + * functions in secp256k1_preallocated.h) before using these contexts to call API + * functions that perform computations involving secret keys, e.g., signing and + * public key generation. It is possible to call this function more than once on + * the same context, and doing so before every few computations involving secret + * keys is recommended as a defense-in-depth measure. Randomization of the static + * context secp256k1_context_static is not supported. + * + * Currently, the random seed is mainly used for blinding multiplications of a + * secret scalar with the elliptic curve base point. Multiplications of this + * kind are performed by exactly those API functions which are documented to + * require a context that is not secp256k1_context_static. As a rule of thumb, + * these are all functions which take a secret key (or a keypair) as an input. + * A notable exception to that rule is the ECDH module, which relies on a different + * kind of elliptic curve point multiplication and thus does not benefit from + * enhanced protection against side-channel leakage currently. + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_context_randomize( + secp256k1_context *ctx, + const unsigned char *seed32 +) SECP256K1_ARG_NONNULL(1); + +/** Add a number of public keys together. + * + * Returns: 1: the sum of the public keys is valid. + * 0: the sum of the public keys is not valid. + * Args: ctx: pointer to a context object. + * Out: out: pointer to a public key object for placing the resulting public key. + * In: ins: pointer to array of pointers to public keys. + * n: the number of public keys to add together (must be at least 1). + */ +SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_ec_pubkey_combine( + const secp256k1_context *ctx, + secp256k1_pubkey *out, + const secp256k1_pubkey * const *ins, + size_t n +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3); + +/** Compute a tagged hash as defined in BIP-340. + * + * This is useful for creating a message hash and achieving domain separation + * through an application-specific tag. This function returns + * SHA256(SHA256(tag)||SHA256(tag)||msg). Therefore, tagged hash + * implementations optimized for a specific tag can precompute the SHA256 state + * after hashing the tag hashes. + * + * Returns: 1 always. + * Args: ctx: pointer to a context object + * Out: hash32: pointer to a 32-byte array to store the resulting hash + * In: tag: pointer to an array containing the tag + * taglen: length of the tag array + * msg: pointer to an array containing the message + * msglen: length of the message array + */ +SECP256K1_API int secp256k1_tagged_sha256( + const secp256k1_context *ctx, + unsigned char *hash32, + const unsigned char *tag, + size_t taglen, + const unsigned char *msg, + size_t msglen +) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3) SECP256K1_ARG_NONNULL(5); + +#ifdef __cplusplus +} +#endif + +#endif /* SECP256K1_H */ diff --git a/confidential/deps/include/secp256k1_mpt.h b/confidential/deps/include/secp256k1_mpt.h new file mode 100644 index 000000000..c4e2cf26d --- /dev/null +++ b/confidential/deps/include/secp256k1_mpt.h @@ -0,0 +1,454 @@ +#ifndef SECP256K1_MPT_H +#define SECP256K1_MPT_H + +#include "mpt_protocol.h" +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Generates a new secp256k1 key pair. + */ +SECP256K1_API int +secp256k1_elgamal_generate_keypair( + secp256k1_context const* ctx, + unsigned char* privkey, + secp256k1_pubkey* pubkey); + +/** + * @brief Encrypts a 64-bit amount using ElGamal. + */ +SECP256K1_API int +secp256k1_elgamal_encrypt( + secp256k1_context const* ctx, + secp256k1_pubkey* c1, + secp256k1_pubkey* c2, + secp256k1_pubkey const* pubkey_Q, + uint64_t amount, + unsigned char const* blinding_factor); + +/** + * @brief Decrypts an ElGamal ciphertext to recover the amount. + * + * The function uses a linear discrete-logarithm search over the caller- + * specified range [range_low, range_high] and can only successfully recover + * plaintext amounts within that range. If the ciphertext encrypts a value + * outside the range, the search exhausts its iterations and the function + * returns 0 (not found). The recommended default range is [0, 1,000,000]. + * + * To mitigate amount-dependent timing side channels (TOB-RIPCTX-1 / -4, + * reintroduced as TOB-RIPCTXR-1), the search executes with a fixed + * iteration count: it always runs to completion over the full specified + * range regardless of where (or whether) the match is found. This is a + * fixed iteration count, not strict constant time: the underlying + * libsecp256k1 curve operations are inherently variable-time, so this + * function does not protect against attackers that can observe + * microarchitectural variation of individual point operations. In shared / + * multitenant deployments where such attacks are relevant, callers should + * not expose decryption latency to untrusted observers. + * + * Architectural note: on-chain validators and verifiers never decrypt + * ciphertexts; this function is intended for testing and basic client- + * side operations. Off-chain applications (wallets, audit tooling) that + * need to decrypt larger balances should use more efficient discrete + * logarithm algorithms such as baby-step giant-step (O(sqrt(n))) or + * Pollard's kangaroo. + * + * @param[in] ctx A pointer to a valid secp256k1 context. + * @param[out] amount Set to the decrypted plaintext amount on success. + * @param[in] c1 The C1 component of the ElGamal ciphertext. + * @param[in] c2 The C2 component of the ElGamal ciphertext. + * @param[in] privkey The 32-byte ElGamal private key. + * @param[in] range_low Lower bound of the search range (inclusive). + * @param[in] range_high Upper bound of the search range (inclusive). + * Must be >= range_low and must not be UINT64_MAX; + * either condition returns 0 immediately. UINT64_MAX + * is rejected because the loop runs + * range_high - max(1, range_low) + 1 iterations — a + * UINT64_MAX upper bound would require up to 2^64 - 1 + * iterations (effectively infinite). Use + * secp256k1_elgamal_decrypt_bsgs for larger ranges. + * Recommended default: range_low=0, range_high=1000000. + * + * @return 1 if the ciphertext decrypts to an amount in [range_low, range_high] + * and `*amount` is set; 0 otherwise. A 0 return covers both + * "amount out of range" and "internal failure". + */ +SECP256K1_API int +secp256k1_elgamal_decrypt( + secp256k1_context const* ctx, + uint64_t* amount, + secp256k1_pubkey const* c1, + secp256k1_pubkey const* c2, + unsigned char const* privkey, + uint64_t range_low, + uint64_t range_high); + +/** + * @brief Homomorphically adds two ElGamal ciphertexts. + */ +SECP256K1_API int +secp256k1_elgamal_add( + secp256k1_context const* ctx, + secp256k1_pubkey* sum_c1, + secp256k1_pubkey* sum_c2, + secp256k1_pubkey const* a_c1, + secp256k1_pubkey const* a_c2, + secp256k1_pubkey const* b_c1, + secp256k1_pubkey const* b_c2); + +/** + * @brief Homomorphically subtracts two ElGamal ciphertexts. + */ +SECP256K1_API int +secp256k1_elgamal_subtract( + secp256k1_context const* ctx, + secp256k1_pubkey* diff_c1, + secp256k1_pubkey* diff_c2, + secp256k1_pubkey const* a_c1, + secp256k1_pubkey const* a_c2, + secp256k1_pubkey const* b_c1, + secp256k1_pubkey const* b_c2); + +/** + * @brief Generates the canonical encrypted zero for a given MPT token instance. + * + * This ciphertext represents a zero balance for a specific account's holding + * of a token defined by its MPTokenIssuanceID. + * + * @param[in] ctx A pointer to a valid secp256k1 context. + * @param[out] enc_zero_c1 The C1 component of the canonical ciphertext. + * @param[out] enc_zero_c2 The C2 component of the canonical ciphertext. + * @param[in] pubkey The ElGamal public key of the account holder. + * @param[in] account_id A pointer to the 20-byte AccountID. + * @param[in] mpt_issuance_id A pointer to the 24-byte MPTokenIssuanceID. + * + * @return 1 on success, 0 on failure. + */ +SECP256K1_API int +generate_canonical_encrypted_zero( + secp256k1_context const* ctx, + secp256k1_pubkey* enc_zero_c1, + secp256k1_pubkey* enc_zero_c2, + secp256k1_pubkey const* pubkey, + unsigned char const* account_id, // 20 bytes + unsigned char const* mpt_issuance_id // 24 bytes +); + +/** + * @brief Computes a Pedersen Commitment: C = value*G + blinding_factor*H. + * + * This function creates the commitment point (C) that the Bulletproof proves + * the range of. + * + * @param[in] ctx A pointer to the context. + * @param[out] commitment_C The resulting commitment point C. + * @param[in] value The secret amount v (uint64_t). + * @param[in] blinding_factor The secret randomness r (32 bytes). + * @param[in] h_generator The Pedersen blinding generator H, as returned + * by secp256k1_mpt_get_h_generator(). This MUST + * be the standardized nothing-up-my-sleeve H + * generator; it must NOT be a holder, issuer, + * auditor, or recipient encryption public key. + * Pedersen binding requires that the discrete + * log of H be unknown to all parties; supplying + * a key whose discrete log is known to any party + * breaks binding and lets that party compute + * alternate openings of the same commitment. + * + * @return 1 on success, 0 on failure. + */ +SECP256K1_API int +secp256k1_bulletproof_create_commitment( + secp256k1_context const* ctx, + secp256k1_pubkey* commitment_C, + uint64_t value, + unsigned char const* blinding_factor, + secp256k1_pubkey const* h_generator); + +int +secp256k1_bulletproof_prove( + secp256k1_context const* ctx, + unsigned char* proof_out, + size_t* proof_len, + uint64_t value, + unsigned char const* blinding_factor, + secp256k1_pubkey const* h_generator, + unsigned char const* context_id, /* <--- AND HERE */ + unsigned int proof_type); + +int +secp256k1_bulletproof_verify( + secp256k1_context const* ctx, + secp256k1_pubkey const* G_vec, + secp256k1_pubkey const* H_vec, + unsigned char const* proof, + size_t proof_len, + secp256k1_pubkey const* commitment_C, + secp256k1_pubkey const* h_generator, /* This is generator H */ + unsigned char const* context_id); +/** + * Verifies that (c1, c2) is a valid ElGamal encryption of 'amount' + * for 'pubkey_Q' using the revealed 'blinding_factor'. + */ +int +secp256k1_elgamal_verify_encryption( + secp256k1_context const* ctx, + secp256k1_pubkey const* c1, + secp256k1_pubkey const* c2, + secp256k1_pubkey const* pubkey_Q, + uint64_t amount, + unsigned char const* blinding_factor); + +/** Proof of Knowledge of Secret Key for Registration. + * Compact form: (e, s) in Z_q^2 = 64 bytes. + * Domain: "CMPT_POK_SK_REGISTER" */ +#define SECP256K1_POK_SK_PROOF_SIZE 64 + +SECP256K1_API int +secp256k1_mpt_pok_sk_prove( + secp256k1_context const* ctx, + unsigned char* proof, /* Expected size: 64 bytes */ + secp256k1_pubkey const* pk, + unsigned char const* sk, + unsigned char const* context_id); + +SECP256K1_API int +secp256k1_mpt_pok_sk_verify( + secp256k1_context const* ctx, + unsigned char const* proof, /* Expected size: 64 bytes */ + secp256k1_pubkey const* pk, + unsigned char const* context_id); + +/** + * Compute a Pedersen Commitment: PC = m*G + rho*H + * Returns 1 on success, 0 on failure. + */ +int +secp256k1_mpt_pedersen_commit( + secp256k1_context const* ctx, + secp256k1_pubkey* commitment, + uint64_t amount, + unsigned char const* blinding_factor_rho /* 32 bytes */ +); + +/** Get the standardized H generator for Pedersen Commitments */ +int +secp256k1_mpt_get_h_generator(secp256k1_context const* ctx, secp256k1_pubkey* h); + +/** + * @brief Generates a vector of N independent NUMS generators. + */ +int +secp256k1_mpt_get_generator_vector( + secp256k1_context const* ctx, + secp256k1_pubkey* vec, + size_t n, + unsigned char const* label, + size_t label_len); + +void +secp256k1_mpt_scalar_add(unsigned char* res, unsigned char const* a, unsigned char const* b); +void +secp256k1_mpt_scalar_mul(unsigned char* res, unsigned char const* a, unsigned char const* b); +/* Computes the modular inverse of a scalar. Returns 1 on success, + * 0 if the input is zero (inverse undefined). Callers must check + * the return value. */ + +int +secp256k1_mpt_scalar_inverse(unsigned char* res, unsigned char const* in); +void +secp256k1_mpt_scalar_negate(unsigned char* res, unsigned char const* in); +void +secp256k1_mpt_scalar_reduce32(unsigned char out32[32], unsigned char const in32[32]); + +int +secp256k1_bulletproof_prove_agg( + secp256k1_context const* ctx, + unsigned char* proof_out, + size_t* proof_len, + uint64_t const* values, + unsigned char const* blindings_flat, + size_t m, + secp256k1_pubkey const* h_generator, + unsigned char const* context_id); +int +secp256k1_bulletproof_verify_agg( + secp256k1_context const* ctx, + secp256k1_pubkey const* G_vec, /* length n = 64*m */ + secp256k1_pubkey const* H_vec, /* length n = 64*m */ + unsigned char const* proof, + size_t proof_len, + secp256k1_pubkey const* commitment_C_vec, /* length m */ + size_t m, + secp256k1_pubkey const* h_generator, + unsigned char const* context_id); + +/* +================================================================================ +| | +| AND-COMPOSED COMPACT SIGMA PROOF (STANDARD EG) | +| | +================================================================================ + * + * Combines ciphertext equality, Pedersen linkage, and balance verification + * into a single sigma protocol under a shared Fiat-Shamir challenge. + * + * Language: exists (r, m, sk_A, rho, b) in Z_q^5 such that: + * C1 = r*G + * C_{2,i} = m*G + r*pk_i for i = 1..n + * PC_m = m*G + r*H + * pk_A = sk_A*G + * PC_b = b*G + rho*H + * B2 - b*G = sk_A*B1 + * + * Compact proof: (e, z_m, z_r, z_b, z_rho, z_sk) in Z_q^6 = 192 bytes. + * Fiat-Shamir domain: "CMPT_SEND_SIGMA" + */ + +/** Serialized size of the compact standard proof in bytes. */ +#define SECP256K1_COMPACT_STANDARD_PROOF_SIZE 192 + +/** + * @brief Generate a compact AND-composed sigma proof for standard EC-ElGamal. + * + * proof_out must point to a buffer of SECP256K1_COMPACT_STANDARD_PROOF_SIZE + * bytes. context_id is an optional 32-byte transaction context (may be NULL). + */ +SECP256K1_API int +secp256k1_compact_standard_prove( + secp256k1_context const* ctx, + unsigned char* proof_out, + uint64_t amount, + uint64_t balance, + unsigned char const* r_shared, + unsigned char const* sk_A, + unsigned char const* r_b, + size_t n, + secp256k1_pubkey const* C1, + secp256k1_pubkey const* C2_vec, + secp256k1_pubkey const* Pk_vec, + secp256k1_pubkey const* PC_m, + secp256k1_pubkey const* pk_A, + secp256k1_pubkey const* PC_b, + secp256k1_pubkey const* B1, + secp256k1_pubkey const* B2, + unsigned char const* context_id); + +/** + * @brief Verify a compact AND-composed sigma proof for standard EC-ElGamal. + * + * Returns 1 if the proof is valid, 0 otherwise. + */ +SECP256K1_API int +secp256k1_compact_standard_verify( + secp256k1_context const* ctx, + unsigned char const* proof, + size_t n, + secp256k1_pubkey const* C1, + secp256k1_pubkey const* C2_vec, + secp256k1_pubkey const* Pk_vec, + secp256k1_pubkey const* PC_m, + secp256k1_pubkey const* pk_A, + secp256k1_pubkey const* PC_b, + secp256k1_pubkey const* B1, + secp256k1_pubkey const* B2, + unsigned char const* context_id); + +/* +================================================================================ +| | +| COMPACT SIGMA PROOF — CLAWBACK | +| | +================================================================================ + * + * Proves the issuer knows sk_iss consistent with the on-ledger mirror + * ciphertext (C1, C2) and the publicly declared amount m: + * P_iss = sk_iss * G + * C2 - m*G = sk_iss * C1 + * + * Compact proof: (e, z_sk) in Z_q^2 = 64 bytes. + * Fiat-Shamir domain: "CMPT_CLAWBACK_SIGMA" + */ + +#define SECP256K1_COMPACT_CLAWBACK_PROOF_SIZE 64 + +SECP256K1_API int +secp256k1_compact_clawback_prove( + secp256k1_context const* ctx, + unsigned char* proof_out, + uint64_t amount, + unsigned char const* sk_iss, + secp256k1_pubkey const* P_iss, + secp256k1_pubkey const* C1, + secp256k1_pubkey const* C2, + unsigned char const* context_id); + +SECP256K1_API int +secp256k1_compact_clawback_verify( + secp256k1_context const* ctx, + unsigned char const* proof, + uint64_t amount, + secp256k1_pubkey const* P_iss, + secp256k1_pubkey const* C1, + secp256k1_pubkey const* C2, + unsigned char const* context_id); + +/* +================================================================================ +| | +| COMPACT SIGMA PROOF — CONVERTBACK | +| | +================================================================================ + * + * AND-composed proof for balance linkage in a ConvertBack withdrawal. + * The withdrawal ciphertext (C1_w, C2_w) is verified deterministically + * using the publicly disclosed r_w (BlindingFactor field), so the sigma + * proof covers only key ownership, balance decryption, and commitment. + * + * Language: exists (b, sk_A, rho) in Z_q^3 such that: + * P_A = sk_A*G + * B2 - b*G = sk_A*B1 + * PC_b = b*G + rho*H + * + * Compact proof: (e, z_b, z_rho, z_sk) in Z_q^4 = 128 bytes. + * Fiat-Shamir domain: "CMPT_CONVERTBACK_SIGMA" + * + * The caller must separately verify the withdrawal ciphertext: + * C1_w == r_w*G and C2_w == m*G + r_w*P_A + * using secp256k1_elgamal_verify_encryption() or equivalent. + */ + +#define SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE 128 + +SECP256K1_API int +secp256k1_compact_convertback_prove( + secp256k1_context const* ctx, + unsigned char* proof_out, + uint64_t balance, + unsigned char const* sk_A, + unsigned char const* rho, + secp256k1_pubkey const* pk_A, + secp256k1_pubkey const* B1, + secp256k1_pubkey const* B2, + secp256k1_pubkey const* PC_b, + unsigned char const* context_id); + +SECP256K1_API int +secp256k1_compact_convertback_verify( + secp256k1_context const* ctx, + unsigned char const* proof, + secp256k1_pubkey const* pk_A, + secp256k1_pubkey const* B1, + secp256k1_pubkey const* B2, + secp256k1_pubkey const* PC_b, + unsigned char const* context_id); + +#ifdef __cplusplus +} +#endif + +#endif // SECP256K1_MPT_H diff --git a/confidential/deps/include/utility/mpt_utility.h b/confidential/deps/include/utility/mpt_utility.h new file mode 100644 index 000000000..46758a5ab --- /dev/null +++ b/confidential/deps/include/utility/mpt_utility.h @@ -0,0 +1,486 @@ +#ifndef MPT_UTILITY_H +#define MPT_UTILITY_H + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Represents a unique 24-byte MPT issuance ID. + */ +typedef struct +{ + uint8_t bytes[kMPT_ISSUANCE_ID_SIZE]; +} mpt_issuance_id; + +/** + * @brief Represents a 20-byte account ID. + * + * - bytes: Raw 20-byte array containing the AccountID. + */ +typedef struct account_id +{ + uint8_t bytes[kMPT_ACCOUNT_ID_SIZE]; +} account_id; + +/** + * @brief Represents a participant in a Confidential Send transaction. + * + * - pubkey: The 33-byte compressed secp256k1 public key. + * - ciphertext: The 66-byte ElGamal encrypted amount. + */ +typedef struct mpt_confidential_participant +{ + uint8_t pubkey[kMPT_PUBKEY_SIZE]; + uint8_t ciphertext[kMPT_ELGAMAL_TOTAL_SIZE]; +} mpt_confidential_participant; + +/** + * @brief Parameters required to generate a Pedersen Linkage Proof. + * + * - pedersen_commitment: The 64-byte Pedersen commitment. + * - amount: The actual numeric value being committed. + * - ciphertext: The 66-byte buffer containing the encrypted amount. + * - blinding_factor: The 32-byte secret value used to blind the commitment. + */ +typedef struct mpt_pedersen_proof_params +{ + uint8_t pedersen_commitment[kMPT_PEDERSEN_COMMIT_SIZE]; + uint64_t amount; + uint8_t ciphertext[kMPT_ELGAMAL_TOTAL_SIZE]; + uint8_t blinding_factor[kMPT_BLINDING_FACTOR_SIZE]; +} mpt_pedersen_proof_params; + +/** + * @brief Returns a globally shared secp256k1 context. + */ +secp256k1_context* +mpt_secp256k1_context(); + +/** + * @brief Context Hash for ConfidentialMPTConvert. + */ +int +mpt_get_convert_context_hash( + account_id account, + mpt_issuance_id iss, + uint32_t sequence, + uint8_t out_hash[kMPT_HALF_SHA_SIZE]); + +/** + * @brief Context Hash for ConfidentialMPTConvertBack. + */ +int +mpt_get_convert_back_context_hash( + account_id acc, + mpt_issuance_id iss, + uint32_t seq, + uint32_t ver, + uint8_t out_hash[kMPT_HALF_SHA_SIZE]); + +/** + * @brief Context Hash for ConfidentialMPTSend. + */ +int +mpt_get_send_context_hash( + account_id acc, + mpt_issuance_id iss, + uint32_t seq, + account_id dest, + uint32_t ver, + uint8_t out_hash[kMPT_HALF_SHA_SIZE]); + +/** + * @brief Context Hash for ConfidentialMPTClawback. + */ +int +mpt_get_clawback_context_hash( + account_id acc, + mpt_issuance_id iss, + uint32_t seq, + account_id holder, + uint8_t out_hash[kMPT_HALF_SHA_SIZE]); + +/* ============================================================================ + * Key & Ciphertext Utilities + * ============================================================================ */ + +/** + * @brief Parses a 66-byte buffer into two internal secp256k1 public keys. + * @param buffer [in] 66-byte buffer containing two points. + * @param out1 [out] First internal public key (C1). + * @param out2 [out] Second internal public key (C2). + * @return true on success, false if parsing fails. + */ +bool +mpt_make_ec_pair( + uint8_t const buffer[kMPT_ELGAMAL_TOTAL_SIZE], + secp256k1_pubkey* out1, + secp256k1_pubkey* out2); + +/** + * @brief Serializes two internal secp256k1 public keys into a 66-byte buffer. + * @param in1 [in] Internal format of the first point (C1). + * @param in2 [in] Internal format of the second point (C2). + * @param out [out] 66-byte buffer to write the serialized points. + * @return true if both points were valid and successfully serialized, false otherwise. + */ +bool +mpt_serialize_ec_pair( + secp256k1_pubkey const* in1, + secp256k1_pubkey const* in2, + uint8_t out[kMPT_ELGAMAL_TOTAL_SIZE]); + +/** + * @brief Generates a new Secp256k1 ElGamal keypair. + * @param out_privkey [out] A 32-byte buffer for private key. + * @param out_pubkey [out] A 33-byte buffer for public key. + * @return 0 on success, -1 on failure. + */ +int +mpt_generate_keypair(uint8_t* out_privkey, uint8_t* out_pubkey); + +/** + * @brief Generates a 32-byte blinding factor. + * @param out_factor [out] A 32-byte buffer to store the blinding factor. + * @return 0 on success, -1 on failure. + */ +int +mpt_generate_blinding_factor(uint8_t out_factor[kMPT_BLINDING_FACTOR_SIZE]); + +/** + * @brief Encrypts an uint64 amount using an ElGamal public key. + * @param amount [in] The integer value to encrypt. + * @param pubkey [in] The 33-byte public key. + * @param blinding_factor [in] The 32-byte random blinding factor (scalar r). + * @param out_ciphertext [out] A 66-byte buffer to store the resulting ciphertext (C1, C2). + * @return 0 on success, -1 on failure. + */ +int +mpt_encrypt_amount( + uint64_t amount, + uint8_t const pubkey[kMPT_PUBKEY_SIZE], + uint8_t const blinding_factor[kMPT_BLINDING_FACTOR_SIZE], + uint8_t out_ciphertext[kMPT_ELGAMAL_TOTAL_SIZE]); + +/** + * @brief Decrypts an MPT amount from a ciphertext pair. + * @param ciphertext [in] A 66-byte buffer containing the two points (C1, C2). + * @param privkey [in] The 32-byte private key. + * @param out_amount [out] Pointer to store the decrypted uint64_t amount. + * @param range_low [in] Lower bound of the search range (inclusive). + * @param range_high [in] Upper bound of the search range (inclusive). + * Must satisfy range_low <= range_high and + * range_high != UINT64_MAX. + * @return 0 on success, -1 on failure, -2 if range_low > range_high or + * range_high == UINT64_MAX. + * @note Performance scales linearly with (range_high - range_low). A range of [0, 1,000,000] takes + * approximately 3 seconds on Apple Silicon. Do not pass arbitrarily large ranges. + */ +int +mpt_decrypt_amount( + uint8_t const ciphertext[kMPT_ELGAMAL_TOTAL_SIZE], + uint8_t const privkey[kMPT_PRIVKEY_SIZE], + uint64_t* out_amount, + uint64_t range_low, + uint64_t range_high); + +/* ============================================================================ + * ZKProof Generation + * ============================================================================ */ + +/** + * @brief Generates a Schnorr Proof of Knowledge for a Confidential MPT conversion. + * + * This proof is used in 'ConfidentialMPTConvert' transactions to prove the + * sender possesses the private key associated with the account, binding it + * to the specific transaction via the ctx_hash. + * + * @param pubkey [in] 33-byte public key of the account. + * @param privkey [in] 32-byte private key of the account. + * @param ctx_hash [in] 32-byte hash of the transaction (challenge). + * @param out_proof [out] 64-byte buffer to store the compact Schnorr proof. + * @return 0 on success, -1 on failure. + */ +int +mpt_get_convert_proof( + uint8_t const pubkey[kMPT_PUBKEY_SIZE], + uint8_t const privkey[kMPT_PRIVKEY_SIZE], + uint8_t const ctx_hash[kMPT_HALF_SHA_SIZE], + uint8_t out_proof[kMPT_SCHNORR_PROOF_SIZE]); + +/** + * @brief Computes a Pedersen Commitment point for Confidential MPT. + * @param amount [in] The 64-bit unsigned integer value to commit. + * @param blinding_factor [in] A 32-byte secret scalar (rho) used to hide the amount. + * @param out_commitment [out] A 33-byte buffer to store the commitment + */ +int +mpt_get_pedersen_commitment( + uint64_t amount, + uint8_t const blinding_factor[kMPT_BLINDING_FACTOR_SIZE], + uint8_t out_commitment[kMPT_PEDERSEN_COMMIT_SIZE]); + +/** + * @brief Generates proof for ConfidentialMPTSend. + * + * Produces a compact AND-composed sigma proof (192 bytes) that simultaneously + * proves ciphertext equality, Pedersen commitment linkage, and balance ownership + * under a single Fiat-Shamir challenge, followed by an aggregated Bulletproof + * range proof (754 bytes). Total proof size is fixed at 946 bytes. + * + * pc_m must be computed as m*G + r*H using tx_blinding_factor as the blinding + * factor (not an independent scalar), since the compact sigma proof binds pc_m + * to the ciphertext randomness r. + * + * @param priv [in] The sender's 32-byte private key. + * @param pub [in] The sender's 33-byte public key. + * @param amount [in] The amount being sent. + * @param participants [in] List of participants, including Sender, Dest, Issuer, + * Auditor(optional). + * @param n_participants [in] Number of participants (3 or 4). + * @param tx_blinding_factor [in] The ElGamal randomness r (also blinding factor for pc_m). + * @param context_hash [in] The 32-byte context hash. + * @param amount_commitment [in] Pedersen commitment pc_m = m*G + r*H. + * @param balance_params [in] Includes pedersen_commitment (pc_b), amount (balance), + * blinding_factor (rho), and ciphertext (b1||b2). + * @param out_proof [out] Buffer to receive the proof blob. + * @param out_len [in/out] In: capacity (must be >= 946). Out: bytes written. + * @return 0 on success, -1 on failure. + */ +int +mpt_get_confidential_send_proof( + uint8_t const priv[kMPT_PRIVKEY_SIZE], + uint8_t const pub[kMPT_PUBKEY_SIZE], + uint64_t amount, + mpt_confidential_participant const* participants, + size_t n_participants, + uint8_t const tx_blinding_factor[kMPT_BLINDING_FACTOR_SIZE], + uint8_t const context_hash[kMPT_HALF_SHA_SIZE], + uint8_t const amount_commitment[kMPT_PEDERSEN_COMMIT_SIZE], + mpt_pedersen_proof_params const* balance_params, + uint8_t* out_proof, + size_t* out_len); + +/** + * @brief Generates proof for ConfidentialMPTConvertBack. + * + * Produces a compact AND-composed sigma proof (128 bytes) over the balance + * witness (b, rho, priv), followed by a single Bulletproof range proof (688 + * bytes) over the remainder commitment pc_rem = pc_b - m*G. + * Total proof size: 816 bytes (SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE + + * kMPT_SINGLE_BULLETPROOF_SIZE). + * + * @param priv [in] The holder's 32-byte private key. + * @param pub [in] The holder's 33-byte public key. + * @param context_hash [in] The 32-byte context hash binding the proof to the transaction. + * @param amount [in] The publicly revealed conversion amount m. + * @param params [in] Includes pedersen_commitment (pc_b), blinding_factor (rho), + * amount (balance b), and ciphertext (b1||b2). + * @param out_proof [out] 816-byte buffer for the compact sigma proof and range proof. + * @return 0 on success, -1 on failure. + */ +int +mpt_get_convert_back_proof( + uint8_t const priv[kMPT_PRIVKEY_SIZE], + uint8_t const pub[kMPT_PUBKEY_SIZE], + uint8_t const context_hash[kMPT_HALF_SHA_SIZE], + uint64_t const amount, + mpt_pedersen_proof_params const* params, + uint8_t out_proof[SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE + kMPT_SINGLE_BULLETPROOF_SIZE]); + +/** + * @brief Generates proof for ConfidentialMPTClawback. + * @param priv [in] The issuer's 32-byte private key. + * @param pub [in] The issuer's 33-byte compressed public key. + * @param context_hash [in] The 32-byte context hash binding the proof to the transaction. + * @param amount [in] The publicly known amount to be clawed back. + * @param ciphertext [in] The 66-byte sfIssuerEncryptedBalance blob from the ledger. + * @param out_proof [out] 64-byte buffer for the compact sigma proof. + * @return 0 on success, -1 on failure. + */ +int +mpt_get_clawback_proof( + uint8_t const priv[kMPT_PRIVKEY_SIZE], + uint8_t const pub[kMPT_PUBKEY_SIZE], + uint8_t const context_hash[kMPT_HALF_SHA_SIZE], + uint64_t const amount, + uint8_t const ciphertext[kMPT_ELGAMAL_TOTAL_SIZE], + uint8_t out_proof[SECP256K1_COMPACT_CLAWBACK_PROOF_SIZE]); + +/* ============================================================================ + * Non-ZKP Validation + * ============================================================================ */ + +/** + * @brief Verifies that the ElGamal ciphertexts held by all participants encrypt + * the same revealed plaintext amount under the given blinding factor. + * + * @param amount [in] Actual numeric amount to verify against. + * @param blinding_factor [in] The ElGamal randomness r used to produce all ciphertexts. + * @param holder [in] Holder's public key and ciphertext. + * @param issuer [in] Issuer's public key and ciphertext. + * @param auditor [in] Auditor's public key and ciphertext, optional. + * @return 0 on success, -1 on failure. + */ +int +mpt_verify_revealed_amount( + uint64_t const amount, + uint8_t const blinding_factor[kMPT_BLINDING_FACTOR_SIZE], + mpt_confidential_participant const* holder, + mpt_confidential_participant const* issuer, + mpt_confidential_participant const* auditor); + +/* ============================================================================ + * ZKProof Verifications for Each Transaction + * ============================================================================ */ + +/** + * @brief Verify proof for ConfidentialMPTConvert. + * + * Proves that the sender possesses the private key for the provided public key. + * + * @param proof [in] The 64-byte compact Schnorr proof. + * @param pubkey [in] The 33-byte compressed ElGamal public key. + * @param context_hash [in] The 32-byte transaction context hash. + * @return 0 on success, -1 on failure. + */ +int +mpt_verify_convert_proof( + uint8_t const proof[kMPT_SCHNORR_PROOF_SIZE], + uint8_t const pubkey[kMPT_PUBKEY_SIZE], + uint8_t const context_hash[kMPT_HALF_SHA_SIZE]); + +/** + * @brief Verify proof for ConfidentialMPTConvertBack + * + * Proves that the hidden balance matches the commitment and that + * subtracting the transparent amount results in a non-negative balance. + * + * @param proof [in] 816-byte proof blob (compact sigma || Bulletproof). + * @param pubkey [in] The holder's 33-byte ElGamal public key. + * @param ciphertext [in] The holder's 66-byte balance ciphertext. + * @param balance_commitment [in] The 33-byte Pedersen commitment to the balance. + * @param amount [in] The publicly revealed conversion amount m. + * @param context_hash [in] The 32-byte transaction context hash. + * @return 0 on success, -1 on failure. + */ +int +mpt_verify_convert_back_proof( + uint8_t const proof[SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE + kMPT_SINGLE_BULLETPROOF_SIZE], + uint8_t const pubkey[kMPT_PUBKEY_SIZE], + uint8_t const ciphertext[kMPT_ELGAMAL_TOTAL_SIZE], + uint8_t const balance_commitment[kMPT_PEDERSEN_COMMIT_SIZE], + uint64_t const amount, + uint8_t const context_hash[kMPT_HALF_SHA_SIZE]); + +/** + * @brief Verify proof for ConfidentialMPTSend. + * + * Verifies the compact AND-composed sigma proof (first 192 bytes) that proves + * ciphertext correctness, Pedersen commitment linkage, and balance ownership, + * followed by an aggregated Bulletproof range proof (next 754 bytes). + * Proof size is fixed at 946 bytes. + * + * @param proof [in] 946-byte proof blob (compact sigma || Bulletproof). + * @param participants [in] List of participants' public keys and ciphertexts. + * participants[0] is the sender. + * @param n_participants [in] Number of participants (3 or 4). + * @param sender_spending_ciphertext [in] The sender's on-ledger balance ciphertext (b1||b2). + * @param amount_commitment [in] Pedersen commitment pc_m to the transfer amount. + * @param balance_commitment [in] Pedersen commitment pc_b to the sender's balance. + * @param context_hash [in] The 32-byte transaction context hash. + * @return 0 on success, -1 on failure. + */ +int +mpt_verify_send_proof( + uint8_t const* proof, + mpt_confidential_participant const* participants, + uint8_t const n_participants, + uint8_t const sender_spending_ciphertext[kMPT_ELGAMAL_TOTAL_SIZE], + uint8_t const amount_commitment[kMPT_PEDERSEN_COMMIT_SIZE], + uint8_t const balance_commitment[kMPT_PEDERSEN_COMMIT_SIZE], + uint8_t const context_hash[kMPT_HALF_SHA_SIZE]); + +/** + * @brief Verify proof for ConfidentialMPTClawback. + * + * Proves that a ciphertext, when decrypted by the issuer, results in exactly the plaintext amount + * specified in the transaction. + * + * @param proof [in] The 64-byte compact sigma proof. + * @param amount [in] The publicly known amount to be clawed back. + * @param pubkey [in] The issuer's 33-byte compressed public key. + * @param ciphertext [in] The 66-byte sfIssuerEncryptedBalance blob associated with the holder's + * account on the ledger. + * @param context_hash [in] The 32-byte transaction context hash. + * @return 0 on success, -1 on failure. + */ +int +mpt_verify_clawback_proof( + uint8_t const proof[SECP256K1_COMPACT_CLAWBACK_PROOF_SIZE], + uint64_t const amount, + uint8_t const pubkey[kMPT_PUBKEY_SIZE], + uint8_t const ciphertext[kMPT_ELGAMAL_TOTAL_SIZE], + uint8_t const context_hash[kMPT_HALF_SHA_SIZE]); + +/** + * @brief Helper function to substract a transparent amount from a hidden commitment. + * + * @param commitment_in [in] The 33-byte starting Pedersen commitment. + * @param amount [in] The integer amount to subtract. + * @param commitment_out [out] The resulting 33-byte remainder commitment. + * @return 0 on success, -1 on failure. + */ +int +mpt_compute_convert_back_remainder( + uint8_t const commitment_in[kMPT_PEDERSEN_COMMIT_SIZE], + uint64_t amount, + uint8_t commitment_out[kMPT_PEDERSEN_COMMIT_SIZE]); + +/** + * @brief Generic verifier for aggregated Bulletproofs (Range Proofs). + * + * @param proof [in] The serialized Bulletproof buffer. + * @param proof_len [in] The length of the proof buffer in bytes. + * @param compressed_commitments [in] An array of pointers to the 33-byte Pedersen commitments. + * @param m [in] The number of commitments to verify. + * @param context_hash [in] The 32-byte context hash binding the proof to the transaction. + * @return 0 on success, -1 on failure. + */ +int +mpt_verify_aggregated_bulletproof( + uint8_t const* proof, + size_t proof_len, + uint8_t const** compressed_commitments, + size_t m, + uint8_t const context_hash[kMPT_HALF_SHA_SIZE]); + +/** + * @brief Verifies that the sending amount and remaining balance reside within the valid range from + * 0 to 2^64-1. + * + * @param ctx [in] secp256k1-zkp context. + * @param proof [in] 754-byte Double Bulletproof. + * @param amount_commitment [in] Pedersen commitment to the transfer amount. + * @param balance_commitment [in] Pedersen commitment to the sender's total balance. + * @param context_hash [in] 32-byte transaction context hash. + * @return 0 on success, -1 on failure. + */ +int +mpt_verify_send_range_proof( + uint8_t const proof[kMPT_DOUBLE_BULLETPROOF_SIZE], + uint8_t const amount_commitment[kMPT_PEDERSEN_COMMIT_SIZE], + uint8_t const balance_commitment[kMPT_PEDERSEN_COMMIT_SIZE], + uint8_t const context_hash[kMPT_HALF_SHA_SIZE]); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/confidential/deps/libs/darwin-amd64/libcrypto.a b/confidential/deps/libs/darwin-amd64/libcrypto.a new file mode 100644 index 000000000..a120fd807 Binary files /dev/null and b/confidential/deps/libs/darwin-amd64/libcrypto.a differ diff --git a/confidential/deps/libs/darwin-amd64/libmpt-crypto.a b/confidential/deps/libs/darwin-amd64/libmpt-crypto.a new file mode 100644 index 000000000..206395c2d Binary files /dev/null and b/confidential/deps/libs/darwin-amd64/libmpt-crypto.a differ diff --git a/confidential/deps/libs/darwin-amd64/libsecp256k1.a b/confidential/deps/libs/darwin-amd64/libsecp256k1.a new file mode 100644 index 000000000..07227f462 Binary files /dev/null and b/confidential/deps/libs/darwin-amd64/libsecp256k1.a differ diff --git a/confidential/deps/libs/darwin-arm64/libcrypto.a b/confidential/deps/libs/darwin-arm64/libcrypto.a new file mode 100644 index 000000000..a79ed7cbf Binary files /dev/null and b/confidential/deps/libs/darwin-arm64/libcrypto.a differ diff --git a/confidential/deps/libs/darwin-arm64/libmpt-crypto.a b/confidential/deps/libs/darwin-arm64/libmpt-crypto.a new file mode 100644 index 000000000..b63112ae7 Binary files /dev/null and b/confidential/deps/libs/darwin-arm64/libmpt-crypto.a differ diff --git a/confidential/deps/libs/darwin-arm64/libsecp256k1.a b/confidential/deps/libs/darwin-arm64/libsecp256k1.a new file mode 100644 index 000000000..758b8afbc Binary files /dev/null and b/confidential/deps/libs/darwin-arm64/libsecp256k1.a differ diff --git a/confidential/deps/libs/linux-amd64/libcrypto.a b/confidential/deps/libs/linux-amd64/libcrypto.a new file mode 100644 index 000000000..2d312da61 Binary files /dev/null and b/confidential/deps/libs/linux-amd64/libcrypto.a differ diff --git a/confidential/deps/libs/linux-amd64/libmpt-crypto.a b/confidential/deps/libs/linux-amd64/libmpt-crypto.a new file mode 100644 index 000000000..e21914c7d Binary files /dev/null and b/confidential/deps/libs/linux-amd64/libmpt-crypto.a differ diff --git a/confidential/deps/libs/linux-amd64/libsecp256k1.a b/confidential/deps/libs/linux-amd64/libsecp256k1.a new file mode 100644 index 000000000..7f1dd2e40 Binary files /dev/null and b/confidential/deps/libs/linux-amd64/libsecp256k1.a differ diff --git a/confidential/deps/libs/linux-arm64/libcrypto.a b/confidential/deps/libs/linux-arm64/libcrypto.a new file mode 100644 index 000000000..4591e7b20 Binary files /dev/null and b/confidential/deps/libs/linux-arm64/libcrypto.a differ diff --git a/confidential/deps/libs/linux-arm64/libmpt-crypto.a b/confidential/deps/libs/linux-arm64/libmpt-crypto.a new file mode 100644 index 000000000..cd3b82d71 Binary files /dev/null and b/confidential/deps/libs/linux-arm64/libmpt-crypto.a differ diff --git a/confidential/deps/libs/linux-arm64/libsecp256k1.a b/confidential/deps/libs/linux-arm64/libsecp256k1.a new file mode 100644 index 000000000..ce602916e Binary files /dev/null and b/confidential/deps/libs/linux-arm64/libsecp256k1.a differ diff --git a/confidential/deps/update.sh b/confidential/deps/update.sh new file mode 100755 index 000000000..f57bb55f6 --- /dev/null +++ b/confidential/deps/update.sh @@ -0,0 +1,451 @@ +#!/usr/bin/env bash +# Maintainer script: fetches the latest mpt-crypto from the XRPLF Conan remote +# and copies static libraries + headers into the vendored deps directory. +# +# Prerequisites: +# - conan >= 2.0 (pip install conan) +# - xrplf remote: conan remote add --index 0 xrplf https://conan.ripplex.io +# +# Usage: +# bash confidential/deps/update.sh # fetch latest, current platform +# bash confidential/deps/update.sh --version 0.2.0-rc1 # fetch specific version +# bash confidential/deps/update.sh --platform linux-arm64 # target specific platform +# bash confidential/deps/update.sh --lockfile conan.lock # use a pre-resolved Conan graph +# bash confidential/deps/update.sh --force # ignore VERSION file, re-fetch +# +# Supported platforms: linux-amd64, linux-arm64, darwin-arm64, darwin-amd64 +# +# For multi-platform builds, run on each platform natively (e.g., CI matrix) +# or use Conan cross-compilation profiles. + +set -euo pipefail + +# --- Argument parsing --- + +VERSION="" +PLATFORM="" +LOCKFILE="" +FORCE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="$2" + shift 2 + ;; + --platform) + PLATFORM="$2" + shift 2 + ;; + --lockfile) + LOCKFILE="$2" + shift 2 + ;; + --force) + FORCE=true + shift + ;; + -h | --help) + sed -n '2,/^$/s/^# \?//p' "$0" + exit 0 + ;; + *) + # Bare arg = version (backward compat) + VERSION="$1" + shift + ;; + esac +done + +# --- Preflight checks --- + +if ! command -v conan &>/dev/null; then + echo "ERROR: conan is not installed." + echo "" + echo "Install it with:" + echo " pip install conan" + echo "" + echo "Then set up a default profile:" + echo " conan profile detect" + exit 1 +fi + +if ! command -v python3 &>/dev/null; then + echo "ERROR: python3 is required to resolve versions and filter headers." + exit 1 +fi + +if ! command -v cc &>/dev/null; then + echo "ERROR: a C compiler is required to resolve the public header dependencies." + exit 1 +fi + +if ! command -v shasum &>/dev/null; then + echo "ERROR: shasum is required to create and verify the header manifest." + exit 1 +fi + +if ! conan remote list 2>/dev/null | grep -q "xrplf"; then + echo "ERROR: the 'xrplf' Conan remote is not configured." + echo "" + echo "Add it with:" + echo " conan remote add --index 0 xrplf https://conan.ripplex.io" + exit 1 +fi + +# --- Configuration --- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERSION_FILE="$SCRIPT_DIR/VERSION" + +# Detect or validate platform +if [ -z "$PLATFORM" ]; then + DETECTED_OS=$(uname -s | tr '[:upper:]' '[:lower:]') + DETECTED_ARCH=$(uname -m) + case "$DETECTED_ARCH" in + x86_64) DETECTED_ARCH="amd64" ;; + aarch64 | arm64) DETECTED_ARCH="arm64" ;; + esac + PLATFORM="${DETECTED_OS}-${DETECTED_ARCH}" +fi + +# Map platform to Conan settings +case "$PLATFORM" in +linux-amd64) + CONAN_OS="Linux" + CONAN_ARCH="x86_64" + ;; +linux-arm64) + CONAN_OS="Linux" + CONAN_ARCH="armv8" + ;; +darwin-amd64) + CONAN_OS="Macos" + CONAN_ARCH="x86_64" + ;; +darwin-arm64) + CONAN_OS="Macos" + CONAN_ARCH="armv8" + ;; +*) + echo "ERROR: unsupported platform '$PLATFORM'" + echo "Supported: linux-amd64, linux-arm64, darwin-amd64, darwin-arm64" + exit 1 + ;; +esac + +LIBS_DIR="$SCRIPT_DIR/libs/$PLATFORM" +INCLUDE_DIR="$SCRIPT_DIR/include" + +if [ -n "$LOCKFILE" ]; then + if [ ! -f "$LOCKFILE" ]; then + echo "ERROR: Conan lockfile not found: $LOCKFILE" + exit 1 + fi + LOCKFILE="$(cd "$(dirname "$LOCKFILE")" && pwd)/$(basename "$LOCKFILE")" +fi + +# --- Resolve version --- + +if [ -z "$VERSION" ]; then + echo "==> Querying latest mpt-crypto version from xrplf remote..." + VERSION=$(conan list 'mpt-crypto/*' -r xrplf --format=json | + python3 -c ' +import json +import re +import sys + +packages = json.load(sys.stdin) +versions = [ + reference.split("/", 1)[1] + for remote in packages.values() + for reference in remote + if reference.startswith("mpt-crypto/") +] +if not versions: + raise SystemExit("no mpt-crypto versions found") + +def version_key(version): + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?", version) + if not match: + raise SystemExit(f"unsupported mpt-crypto version format: {version}") + prerelease = match.group(4) + prerelease_key = tuple( + (0, int(part)) if part.isdigit() else (1, part) + for part in re.split(r"[.-]", prerelease or "") + ) + return (*(int(match.group(index)) for index in range(1, 4)), prerelease is None, prerelease_key) + +print(max(versions, key=version_key)) +') + echo "==> Latest version: $VERSION" +else + echo "==> Using specified version: $VERSION" +fi + +if [[ ! "$VERSION" =~ ^[0-9A-Za-z][0-9A-Za-z._+-]*$ ]]; then + echo "ERROR: invalid mpt-crypto version '$VERSION'" + exit 1 +fi + +# --- Check if already up to date --- + +bundle_is_complete() { + local required + for required in \ + "$LIBS_DIR/libmpt-crypto.a" \ + "$LIBS_DIR/libsecp256k1.a" \ + "$LIBS_DIR/libcrypto.a" \ + "$INCLUDE_DIR/mpt_protocol.h" \ + "$INCLUDE_DIR/secp256k1_mpt.h" \ + "$INCLUDE_DIR/secp256k1.h" \ + "$INCLUDE_DIR/utility/mpt_utility.h"; do + [ -s "$required" ] || return 1 + done +} + +if [ "$FORCE" = false ]; then + CURRENT_VERSION="" + if [ -f "$VERSION_FILE" ]; then + CURRENT_VERSION=$(cat "$VERSION_FILE") + fi + + if [ "$CURRENT_VERSION" = "$VERSION" ] && bundle_is_complete; then + echo "==> Already at mpt-crypto/$VERSION for $PLATFORM. Nothing to do." + echo " Use --force to re-fetch." + exit 0 + fi + + if [ "$CURRENT_VERSION" = "$VERSION" ]; then + echo "==> Repairing incomplete mpt-crypto/$VERSION bundle for $PLATFORM" + elif [ -n "$CURRENT_VERSION" ]; then + echo "==> Updating: $CURRENT_VERSION -> $VERSION" + else + echo "==> Installing mpt-crypto/$VERSION for $PLATFORM" + fi +fi + +# --- Fetch via Conan --- + +WORK_DIR=$(mktemp -d) +HEADER_MANIFEST="$WORK_DIR/MANIFEST.sha256" +LIBS_NEXT="${LIBS_DIR}.next.$$" +INCLUDE_NEXT="${INCLUDE_DIR}.next.$$" +VERSION_NEXT="${VERSION_FILE}.next.$$" + +cleanup() { + rm -rf "$WORK_DIR" "$LIBS_NEXT" "$INCLUDE_NEXT" "$VERSION_NEXT" +} +trap cleanup EXIT + +cat >"$WORK_DIR/conanfile.txt" < Running conan install for $PLATFORM (this may build from source on first run)..." +CONAN_INSTALL_ARGS=( + --build=missing + -o "*:shared=False" + -o "*:fPIC=True" + -s "os=$CONAN_OS" + -s "arch=$CONAN_ARCH" + --deployer=full_deploy + --deployer-folder "$WORK_DIR/deploy" +) +if [ -n "$LOCKFILE" ]; then + CONAN_INSTALL_ARGS+=(--lockfile "$LOCKFILE") +fi +conan install . "${CONAN_INSTALL_ARGS[@]}" + +# --- Stage the resolved CGO bundle --- + +DEPLOY_ROOT="$WORK_DIR/deploy/full_deploy/host" +STAGE_LIBS="$WORK_DIR/stage/libs" +AVAILABLE_INCLUDE="$WORK_DIR/available-include" +STAGE_INCLUDE="$WORK_DIR/stage/include" +HEADER_PROBE="$WORK_DIR/header_probe.c" +HEADER_DEPS="$WORK_DIR/header_probe.d" + +if [ ! -d "$DEPLOY_ROOT" ]; then + echo "ERROR: Conan full deploy output not found at $DEPLOY_ROOT" + exit 1 +fi + +find_unique_archive() { + local archive="$1" + local matches + local count + + matches=$(find "$DEPLOY_ROOT" -type f -path "*/lib/$archive" -print) + if [ -z "$matches" ]; then + echo "ERROR: $archive not found in the resolved Conan graph" >&2 + return 1 + fi + + count=$(printf '%s\n' "$matches" | grep -c .) + if [ "$count" -ne 1 ]; then + echo "ERROR: expected one $archive in the resolved Conan graph, found $count:" >&2 + printf '%s\n' "$matches" >&2 + return 1 + fi + + printf '%s\n' "$matches" +} + +merge_include_tree() { + local package_root="$1" + local package_name="$2" + local source_dir="$package_root/include" + local source_file + local relative_path + local destination_file + local header_count + + if [ ! -d "$source_dir" ]; then + echo "ERROR: public include tree not found at $source_dir" + exit 1 + fi + + header_count=$(find "$source_dir" -type f -name '*.h' -print | wc -l | tr -d ' ') + if [ "$header_count" -eq 0 ]; then + echo "ERROR: no public headers found at $source_dir" + exit 1 + fi + + while IFS= read -r source_file; do + relative_path=${source_file#"$source_dir/"} + destination_file="$AVAILABLE_INCLUDE/$relative_path" + if [ -e "$destination_file" ] && ! cmp -s "$source_file" "$destination_file"; then + echo "ERROR: conflicting public header: $relative_path" + exit 1 + fi + done < <(find "$source_dir" -type f -print) + + cp -R "$source_dir/." "$AVAILABLE_INCLUDE/" + echo " Discovered: $header_count headers from $package_name" +} + +MPT_LIB=$(find_unique_archive "libmpt-crypto.a") +SECP256K1_LIB=$(find_unique_archive "libsecp256k1.a") +CRYPTO_LIB=$(find_unique_archive "libcrypto.a") +MPT_PACKAGE_ROOT=${MPT_LIB%/lib/libmpt-crypto.a} +SECP256K1_PACKAGE_ROOT=${SECP256K1_LIB%/lib/libsecp256k1.a} + +mkdir -p "$STAGE_LIBS" "$AVAILABLE_INCLUDE" "$STAGE_INCLUDE" +cp "$MPT_LIB" "$SECP256K1_LIB" "$CRYPTO_LIB" "$STAGE_LIBS/" +merge_include_tree "$MPT_PACKAGE_ROOT" "mpt-crypto" +merge_include_tree "$SECP256K1_PACKAGE_ROOT" "secp256k1" + +# Resolve the downstream header closure from the same root and include paths +# used by the CGO preamble. -MM excludes system headers and fails if a package +# header references an unavailable dependency. +cat >"$HEADER_PROBE" <<'EOF' +#include "mpt_utility.h" +EOF +cc \ + -I"$AVAILABLE_INCLUDE" \ + -I"$AVAILABLE_INCLUDE/utility" \ + -MM \ + -MF "$HEADER_DEPS" \ + "$HEADER_PROBE" + +python3 - "$AVAILABLE_INCLUDE" "$STAGE_INCLUDE" "$HEADER_DEPS" <<'PY' +from pathlib import Path +import shlex +import shutil +import sys + +source_root = Path(sys.argv[1]).resolve() +destination_root = Path(sys.argv[2]) +dependency_file = Path(sys.argv[3]) +dependency_text = dependency_file.read_text().replace("\\\n", " ") +try: + dependency_values = shlex.split(dependency_text.split(":", 1)[1]) +except IndexError as error: + raise SystemExit(f"invalid compiler dependency output: {dependency_text}") from error + +copied = set() +for dependency_value in dependency_values: + dependency = Path(dependency_value).resolve() + try: + relative_path = dependency.relative_to(source_root) + except ValueError: + continue + if relative_path in copied: + continue + if not dependency.is_file(): + raise SystemExit(f"resolved header is not a file: {dependency}") + destination = destination_root / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(dependency, destination) + copied.add(relative_path) + +if not copied: + raise SystemExit("compiler did not resolve any vendored headers") + +print(f" Filtered: {len(copied)} downstream headers") +for relative_path in sorted(copied): + print(f" {relative_path}") +PY + +( + cd "$STAGE_INCLUDE" + find . -type f -print | + LC_ALL=C sort | + while IFS= read -r header; do shasum -a 256 "$header"; done +) >"$HEADER_MANIFEST" + +for required in \ + "$STAGE_LIBS/libmpt-crypto.a" \ + "$STAGE_LIBS/libsecp256k1.a" \ + "$STAGE_LIBS/libcrypto.a" \ + "$STAGE_INCLUDE/mpt_protocol.h" \ + "$STAGE_INCLUDE/secp256k1_mpt.h" \ + "$STAGE_INCLUDE/secp256k1.h" \ + "$STAGE_INCLUDE/utility/mpt_utility.h"; do + if [ ! -s "$required" ]; then + echo "ERROR: incomplete CGO bundle; missing $required" + exit 1 + fi +done + +if ! (cd "$STAGE_INCLUDE" && shasum -a 256 -c "$HEADER_MANIFEST" >/dev/null); then + echo "ERROR: generated header manifest failed verification" + exit 1 +fi + +# Replace rather than overlay so removed upstream files cannot remain stale. +mkdir -p "$(dirname "$LIBS_DIR")" "$(dirname "$INCLUDE_DIR")" +rm -rf "$LIBS_NEXT" "$INCLUDE_NEXT" +cp -R "$STAGE_LIBS" "$LIBS_NEXT" +cp -R "$STAGE_INCLUDE" "$INCLUDE_NEXT" +rm -rf "$LIBS_DIR" "$INCLUDE_DIR" +mv "$LIBS_NEXT" "$LIBS_DIR" +mv "$INCLUDE_NEXT" "$INCLUDE_DIR" +printf '%s\n' "$VERSION" >"$VERSION_NEXT" +mv "$VERSION_NEXT" "$VERSION_FILE" + +for archive in "$LIBS_DIR"/*.a; do + echo " Copied: $(basename "$archive") ($(du -h "$archive" | cut -f1))" +done + +# --- Summary --- + +echo "" +echo "==> Done! Vendored mpt-crypto/$VERSION for $PLATFORM." +echo "" +echo "Libraries:" +ls -lh "$LIBS_DIR/" +echo "" +echo "Headers:" +find "$INCLUDE_DIR" -type f -print | sort +echo "" +echo "Next steps:" +echo " git add -f confidential/deps/libs/ confidential/deps/include/ confidential/deps/VERSION" +echo " git commit -m 'feat(confidential): update vendored mpt-crypto to $VERSION'" diff --git a/confidential/elgamal/elgamal.go b/confidential/elgamal/elgamal.go new file mode 100644 index 000000000..d4e89a84e --- /dev/null +++ b/confidential/elgamal/elgamal.go @@ -0,0 +1,107 @@ +// Package elgamal provides a hex-string API for ElGamal keypair generation, +// encryption, and decryption. It wraps the byte-array functions in mptcrypto +// with hex encoding/decoding for use with XRPL transaction fields. +package elgamal + +import ( + "encoding/hex" + "fmt" + "math" + + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/Peersyst/xrpl-go/pkg/hexutil" +) + +// Keypair holds a hex-encoded ElGamal keypair. +type Keypair struct { + PrivKeyHex string // 64 hex chars (32 bytes) + PubKeyHex string // 66 hex chars (33 bytes, compressed) +} + +// AmountRange defines inclusive bounds for a decryption search. +type AmountRange struct { + Low uint64 + High uint64 +} + +// Validate checks that the inclusive decryption range can be searched safely. +func (r AmountRange) Validate() error { + if r.Low > r.High { + return fmt.Errorf("%w: low %d exceeds high %d", ErrInvalidAmountRange, r.Low, r.High) + } + if r.High == math.MaxUint64 { + return fmt.Errorf("%w: high must be less than %d", ErrInvalidAmountRange, uint64(math.MaxUint64)) + } + return nil +} + +// GenerateKeypair creates a new secp256k1 ElGamal keypair with hex-encoded keys. +func GenerateKeypair() (Keypair, error) { + priv, pub, err := mptcrypto.GenerateKeypair() + if err != nil { + return Keypair{}, err + } + return Keypair{ + PrivKeyHex: hex.EncodeToString(priv[:]), + PubKeyHex: hex.EncodeToString(pub[:]), + }, nil +} + +// GenerateBlindingFactor returns a random 32-byte scalar as a 64-char hex string. +func GenerateBlindingFactor() (string, error) { + bf, err := mptcrypto.GenerateBlindingFactor() + if err != nil { + return "", err + } + return hex.EncodeToString(bf[:]), nil +} + +// Encrypt encrypts an amount under a compressed public key with a blinding factor. +// pubkeyHex: 66 hex chars (33 bytes), bfHex: 64 hex chars (32 bytes). +// Returns 132 hex chars (66-byte ciphertext). +func Encrypt(amount uint64, pubkeyHex, bfHex string) (string, error) { + pubBytes, err := hexutil.DecodeFixedHex(pubkeyHex, mptcrypto.PubKeySize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidKey, err) + } + bfBytes, err := hexutil.DecodeFixedHex(bfHex, mptcrypto.BlindingFactorSize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidBlindingFactor, err) + } + + pub := mptcrypto.PublicKey(pubBytes) + bf := mptcrypto.BlindingFactor(bfBytes) + + ct, err := mptcrypto.EncryptAmount(amount, pub, bf) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrEncryptFailed, err) + } + return hex.EncodeToString(ct[:]), nil +} + +// Decrypt decrypts a ciphertext using a private key by searching amountRange. +// ciphertextHex: 132 hex chars (66 bytes), privateKeyHex: 64 hex chars (32 bytes). +// The amount range bounds are inclusive and the search cost is linear. +func Decrypt(ciphertextHex, privateKeyHex string, amountRange AmountRange) (uint64, error) { + if err := amountRange.Validate(); err != nil { + return 0, err + } + + ctBytes, err := hexutil.DecodeFixedHex(ciphertextHex, mptcrypto.CiphertextSize) + if err != nil { + return 0, fmt.Errorf("%w: %w", ErrInvalidCiphertext, err) + } + privBytes, err := hexutil.DecodeFixedHex(privateKeyHex, mptcrypto.PrivKeySize) + if err != nil { + return 0, fmt.Errorf("%w: %w", ErrInvalidKey, err) + } + + ct := mptcrypto.Ciphertext(ctBytes) + privateKey := mptcrypto.PrivateKey(privBytes) + + result, err := mptcrypto.DecryptAmount(ct, privateKey, amountRange.Low, amountRange.High) + if err != nil { + return 0, fmt.Errorf("%w: %w", ErrDecryptFailed, err) + } + return result, nil +} diff --git a/confidential/elgamal/elgamal_test.go b/confidential/elgamal/elgamal_test.go new file mode 100644 index 000000000..5f596ed2a --- /dev/null +++ b/confidential/elgamal/elgamal_test.go @@ -0,0 +1,244 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package elgamal_test + +import ( + "math" + "strings" + "testing" + + "github.com/Peersyst/xrpl-go/confidential/elgamal" + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/stretchr/testify/require" +) + +func TestGenerateKeypair(t *testing.T) { + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + // privkey: 64 hex chars (32 bytes) + require.Len(t, kp.PrivKeyHex, mptcrypto.PrivKeySize*2) + + // pubkey: 66 hex chars (33 bytes) + require.Len(t, kp.PubKeyHex, mptcrypto.PubKeySize*2) + + // compressed pubkey starts with "02" or "03" + prefix := kp.PubKeyHex[:2] + require.Contains(t, []string{"02", "03"}, prefix, "PubKeyHex prefix: got %q, want 02 or 03", prefix) +} + +func TestGenerateBlindingFactor(t *testing.T) { + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + + // 64 hex chars (32 bytes) + require.Len(t, bf, mptcrypto.BlindingFactorSize*2) + + // not all zeros + allZeros := true + for _, c := range bf { + if c != '0' { + allZeros = false + break + } + } + require.False(t, allZeros, "blinding factor is all zeros") +} + +func TestEncryptDecryptRoundtrip(t *testing.T) { + tests := []struct { + name string + amount uint64 + amountRange elgamal.AmountRange + }{ + {name: "pass - zero", amount: 0, amountRange: elgamal.AmountRange{Low: 0, High: 0}}, + {name: "pass - small value", amount: 42, amountRange: elgamal.AmountRange{Low: 40, High: 50}}, + {name: "pass - one million", amount: 1_000_000, amountRange: elgamal.AmountRange{Low: 1_000_000, High: 1_000_000}}, + } + + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + + ct, err := elgamal.Encrypt(tt.amount, kp.PubKeyHex, bf) + require.NoError(t, err) + require.Len(t, ct, mptcrypto.CiphertextSize*2) + + got, err := elgamal.Decrypt(ct, kp.PrivKeyHex, tt.amountRange) + require.NoError(t, err) + require.Equal(t, tt.amount, got) + }) + } +} + +func TestAmountRangeValidate(t *testing.T) { + tests := []struct { + name string + amountRange elgamal.AmountRange + wantErr error + }{ + {name: "pass - inclusive range", amountRange: elgamal.AmountRange{Low: 1, High: 2}}, + {name: "pass - single-value range", amountRange: elgamal.AmountRange{Low: 1, High: 1}}, + {name: "fail - low exceeds high", amountRange: elgamal.AmountRange{Low: 2, High: 1}, wantErr: elgamal.ErrInvalidAmountRange}, + {name: "fail - high is max uint64", amountRange: elgamal.AmountRange{Low: 0, High: math.MaxUint64}, wantErr: elgamal.ErrInvalidAmountRange}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.amountRange.Validate() + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestEncryptMultipleKeys(t *testing.T) { + kp1, err := elgamal.GenerateKeypair() + require.NoError(t, err) + kp2, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + // Intentionally reuse the same blinding factor to prove that different + // public keys alone are sufficient to produce distinct ciphertexts. + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + + ct1, err := elgamal.Encrypt(42, kp1.PubKeyHex, bf) + require.NoError(t, err) + ct2, err := elgamal.Encrypt(42, kp2.PubKeyHex, bf) + require.NoError(t, err) + + require.NotEqual(t, ct1, ct2, "same amount with different keys produced identical ciphertexts") +} + +func TestDecryptFailures(t *testing.T) { + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + wrongKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + + ciphertext, err := elgamal.Encrypt(42, kp.PubKeyHex, bf) + require.NoError(t, err) + + tests := []struct { + name string + privateKey string + amountRange elgamal.AmountRange + }{ + {name: "fail - amount outside range", privateKey: kp.PrivKeyHex, amountRange: elgamal.AmountRange{Low: 0, High: 41}}, + {name: "fail - wrong private key", privateKey: wrongKP.PrivKeyHex, amountRange: elgamal.AmountRange{Low: 0, High: 100}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := elgamal.Decrypt(ciphertext, tt.privateKey, tt.amountRange) + require.ErrorIs(t, err, elgamal.ErrDecryptFailed) + }) + } +} + +func TestInvalidHexInputs(t *testing.T) { + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + ciphertext, err := elgamal.Encrypt(1, kp.PubKeyHex, bf) + require.NoError(t, err) + + tests := []struct { + name string + fn func() error + wantErr error + }{ + { + name: "fail - encrypt bad pubkey hex", + fn: func() error { + _, err := elgamal.Encrypt(1, "zzzz", bf) + return err + }, + wantErr: elgamal.ErrInvalidKey, + }, + { + name: "fail - encrypt short pubkey", + fn: func() error { + _, err := elgamal.Encrypt(1, "0102", bf) + return err + }, + wantErr: elgamal.ErrInvalidKey, + }, + { + name: "fail - encrypt bad blinding factor", + fn: func() error { + _, err := elgamal.Encrypt(1, kp.PubKeyHex, "not-hex") + return err + }, + wantErr: elgamal.ErrInvalidBlindingFactor, + }, + { + name: "fail - decrypt bad ciphertext", + fn: func() error { + _, err := elgamal.Decrypt("zz", kp.PrivKeyHex, elgamal.AmountRange{Low: 0, High: 1}) + return err + }, + wantErr: elgamal.ErrInvalidCiphertext, + }, + { + name: "fail - decrypt short ciphertext", + fn: func() error { + _, err := elgamal.Decrypt(strings.Repeat("00", mptcrypto.CiphertextSize-1), kp.PrivKeyHex, elgamal.AmountRange{Low: 0, High: 1}) + return err + }, + wantErr: elgamal.ErrInvalidCiphertext, + }, + { + name: "fail - decrypt long ciphertext", + fn: func() error { + _, err := elgamal.Decrypt(strings.Repeat("00", mptcrypto.CiphertextSize+1), kp.PrivKeyHex, elgamal.AmountRange{Low: 0, High: 1}) + return err + }, + wantErr: elgamal.ErrInvalidCiphertext, + }, + { + name: "fail - decrypt bad privkey", + fn: func() error { + _, err := elgamal.Decrypt(ciphertext, "short", elgamal.AmountRange{Low: 0, High: 1}) + return err + }, + wantErr: elgamal.ErrInvalidKey, + }, + { + name: "fail - decrypt short privkey", + fn: func() error { + _, err := elgamal.Decrypt(ciphertext, strings.Repeat("00", mptcrypto.PrivKeySize-1), elgamal.AmountRange{Low: 0, High: 1}) + return err + }, + wantErr: elgamal.ErrInvalidKey, + }, + { + name: "fail - decrypt long privkey", + fn: func() error { + _, err := elgamal.Decrypt(ciphertext, strings.Repeat("00", mptcrypto.PrivKeySize+1), elgamal.AmountRange{Low: 0, High: 1}) + return err + }, + wantErr: elgamal.ErrInvalidKey, + }, + { + name: "fail - decrypt invalid range before decoding inputs", + fn: func() error { + _, err := elgamal.Decrypt("zz", "short", elgamal.AmountRange{Low: 2, High: 1}) + return err + }, + wantErr: elgamal.ErrInvalidAmountRange, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.ErrorIs(t, tc.fn(), tc.wantErr) + }) + } +} diff --git a/confidential/elgamal/errors.go b/confidential/elgamal/errors.go new file mode 100644 index 000000000..60cf51cc0 --- /dev/null +++ b/confidential/elgamal/errors.go @@ -0,0 +1,18 @@ +package elgamal + +import "errors" + +var ( + // ErrInvalidKey is returned when a key is not valid hex or has an unexpected byte length. + ErrInvalidKey = errors.New("elgamal: invalid key") + // ErrInvalidCiphertext is returned when a ciphertext is not valid hex or has an unexpected byte length. + ErrInvalidCiphertext = errors.New("elgamal: invalid ciphertext") + // ErrInvalidBlindingFactor is returned when a blinding factor is not valid hex or has an unexpected byte length. + ErrInvalidBlindingFactor = errors.New("elgamal: invalid blinding factor") + // ErrEncryptFailed is returned when the underlying C encryption call fails. + ErrEncryptFailed = errors.New("elgamal: encryption failed") + // ErrDecryptFailed is returned when the underlying C decryption call fails. + ErrDecryptFailed = errors.New("elgamal: decryption failed") + // ErrInvalidAmountRange is returned when a decryption search range is invalid. + ErrInvalidAmountRange = errors.New("elgamal: invalid amount range") +) diff --git a/confidential/mptcrypto/README.md b/confidential/mptcrypto/README.md new file mode 100644 index 000000000..b383b0e60 --- /dev/null +++ b/confidential/mptcrypto/README.md @@ -0,0 +1,307 @@ +# mptcrypto + +`mptcrypto` is the low-level Go binding for the [XRPLF/mpt-crypto](https://github.com/XRPLF/mpt-crypto) C library used by XLS-96 Confidential MPT Transfers. It exposes EC-ElGamal encryption, Pedersen commitments, transaction context hashes, and the proof generation and verification routines required by confidential MPT transactions. + +This is the only package in this repository that imports `"C"`. Higher-level packages such as `confidential/elgamal`, `confidential/commitment`, and `confidential/proof` are pure Go wrappers that handle hex encoding, address decoding, and domain-specific errors. + +## Native backend availability + +The native implementation is selected only when all of the following are true: + +- cgo is enabled (`CGO_ENABLED=1`) +- the target OS is Linux or macOS (`darwin`) +- the target architecture is `amd64` or `arm64` +- the build is not targeting `js`, `wasip1`, TinyGo, or go-fuzz + +Building the native implementation also requires the C/C++ compiler and linker toolchain used by cgo (for example, `gcc`/`g++` on Linux or the Xcode command-line tools on macOS). Enabling cgo alone does not install that toolchain. + +Vendored headers and static libraries live under `confidential/deps/`: + +| Target | Library directory | +| --- | --- | +| Linux amd64 | `confidential/deps/libs/linux-amd64/` | +| Linux arm64 | `confidential/deps/libs/linux-arm64/` | +| macOS amd64 | `confidential/deps/libs/darwin-amd64/` | +| macOS arm64 | `confidential/deps/libs/darwin-arm64/` | + +All other builds select `mptcrypto_nocgo.go`. The package still compiles and exposes the same API, but every operation immediately returns `ErrCgoRequired` without validating or processing its inputs. This includes builds with cgo enabled on an unsupported OS or architecture. + +```bash +# Exercise the native implementation on a supported host. +go test ./confidential/mptcrypto + +# Exercise the fallback implementation. +CGO_ENABLED=0 go test ./confidential/mptcrypto +``` + +## Package layout + +```text +mptcrypto/ + types.go # Package documentation, sizes, and value types + errors.go # Shared sentinel errors + mptcrypto_cgo.go # Native bindings and native-only validation + mptcrypto_nocgo.go # Unavailable-backend stubs + mptcrypto_test.go # Native cryptographic tests + mptcrypto_nocgo_test.go # Fallback availability contract +``` + +## Data model + +### Size constants + +All sizes are in bytes and match `confidential/deps/include/utility/mpt_utility.h`. + +| Constant | Bytes | Meaning | +| --- | ---: | --- | +| `PrivKeySize` | 32 | ElGamal private key | +| `PubKeySize` | 33 | Compressed secp256k1 ElGamal public key | +| `BlindingFactorSize` | 32 | ElGamal randomness / Pedersen blinding scalar | +| `CiphertextSize` | 66 | Two compressed EC points (`C1 || C2`) | +| `AccountIDSize` | 20 | Decoded XRPL account ID | +| `IssuanceIDSize` | 24 | MPToken issuance ID | +| `HashOutputSize` | 32 | Transaction context hash | +| `CommitmentSize` | 33 | Compressed Pedersen commitment | +| `SchnorrProofSize` | 64 | Convert Schnorr proof | +| `SingleBulletproofSize` | 688 | Range proof for one value | +| `DoubleBulletproofSize` | 754 | Aggregated range proof for two values | +| `CompactClawbackProofSize` | 64 | Clawback compact sigma proof | +| `CompactConvertBackProofSize` | 128 | Convert-back compact sigma proof | +| `CompactSendProofSize` | 192 | Send compact sigma proof | +| `ConvertBackProofSize` | 816 | `128 + 688` bytes | +| `SendProofSize` | 946 | `192 + 754` bytes | +| `MaxParticipants` | 255 | Maximum representable participant count in the verification C API | + +### Defined byte-array types + +The main cryptographic values use distinct fixed-size types: + +```go +type PrivateKey [PrivKeySize]byte +type PublicKey [PubKeySize]byte +type BlindingFactor [BlindingFactorSize]byte +type Ciphertext [CiphertextSize]byte +type Commitment [CommitmentSize]byte +type ContextHash [HashOutputSize]byte +``` + +These types prevent accidental substitutions between same-sized values. Account IDs and issuance IDs are accepted as `[AccountIDSize]byte` and `[IssuanceIDSize]byte`; proof parameters use fixed-size arrays except for a full send proof, which crosses the API as `[]byte` and is length-checked by `VerifySendProof`. + +The Go types enforce byte lengths, not cryptographic validity. The native library validates private scalars, public keys, curve points, ciphertexts, commitments, and proofs when performing an operation. + +### Compound inputs + +```go +// One encrypted copy of a confidential send amount. +type Participant struct { + PubKey PublicKey + Ciphertext Ciphertext +} + +// A value represented by both an ElGamal ciphertext and a Pedersen commitment. +type PedersenProofParams struct { + Commitment Commitment + Amount uint64 + Ciphertext Ciphertext + BlindingFactor BlindingFactor +} +``` + +For send proofs, XLS-96 uses three participants, or four when an auditor is configured. Their order is part of the native proof contract: + +1. sender +2. destination +3. issuer +4. optional auditor + +Each participant ciphertext must encrypt the transfer amount under that participant's public key using the same transaction blinding factor. The Go wrapper rejects an empty list or more than `MaxParticipants`; the underlying native routine defines the valid XLS-96 count as three or four. + +## Function reference + +### ElGamal + +#### `GenerateKeypair() (PrivateKey, PublicKey, error)` + +Generates a secp256k1 ElGamal keypair. The public key is a 33-byte compressed point. + +#### `GenerateBlindingFactor() (BlindingFactor, error)` + +Generates a random scalar suitable for ElGamal encryption and Pedersen commitments. + +#### `EncryptAmount(amount uint64, pubkey PublicKey, bf BlindingFactor) (Ciphertext, error)` + +Encrypts `amount` under `pubkey` using `bf`. The result is the concatenation of two compressed EC points. + +#### `DecryptAmount(ciphertext Ciphertext, privateKey PrivateKey, rangeLow, rangeHigh uint64) (uint64, error)` + +Searches for the plaintext in the inclusive interval `[rangeLow, rangeHigh]`. On a native build, the range must satisfy: + +```text +rangeLow <= rangeHigh < math.MaxUint64 +``` + +Invalid ranges wrap `ErrInvalidAmountRange`. Decryption cost grows linearly with the interval width, so callers should use the narrowest practical range. If the native backend is unavailable, `ErrCgoRequired` is returned before range validation. + +### Transaction context hashes + +Context hashes bind proofs to transaction-specific fields. All helpers return `ContextHash`. + +```go +func ConvertContextHash( + account [AccountIDSize]byte, + iss [IssuanceIDSize]byte, + seq uint32, +) (ContextHash, error) + +func ConvertBackContextHash( + account [AccountIDSize]byte, + iss [IssuanceIDSize]byte, + seq, ver uint32, +) (ContextHash, error) + +func SendContextHash( + account [AccountIDSize]byte, + iss [IssuanceIDSize]byte, + seq uint32, + dest [AccountIDSize]byte, + ver uint32, +) (ContextHash, error) + +func ClawbackContextHash( + account [AccountIDSize]byte, + iss [IssuanceIDSize]byte, + seq uint32, + holder [AccountIDSize]byte, +) (ContextHash, error) +``` + +The fields correspond to the relevant XLS-96 transaction: + +- convert: holder account, issuance ID, and transaction sequence +- convert back: the same fields plus the holder's confidential balance version +- send: sender, destination, issuance ID, sequence, and sender balance version +- clawback: issuer account, target holder, issuance ID, and sequence + +### Pedersen commitments + +#### `PedersenCommitment(amount uint64, bf BlindingFactor) (Commitment, error)` + +Computes a compressed Pedersen commitment to `amount` using `bf`. The operation is deterministic for the same amount and blinding factor. + +#### `ComputeConvertBackRemainder(commitmentIn Commitment, amount uint64) (Commitment, error)` + +Subtracts the transparent amount from a balance commitment and returns the commitment to the convert-back remainder. + +### Proof generation + +#### `GenerateConvertProof(pubkey PublicKey, privkey PrivateKey, ctxHash ContextHash) ([SchnorrProofSize]byte, error)` + +Generates the Schnorr proof of private-key knowledge used when a `ConfidentialMPTConvert` transaction registers a holder encryption key. + +#### `GenerateConvertBackProof(privkey PrivateKey, pubkey PublicKey, ctxHash ContextHash, amount uint64, params PedersenProofParams) ([ConvertBackProofSize]byte, error)` + +Generates an 816-byte proof containing: + +- a 128-byte compact sigma proof binding the holder key, encrypted spending balance, and balance commitment +- a 688-byte range proof showing that the balance remaining after `amount` is subtracted is non-negative + +`params` describes the holder's original spending balance, ciphertext, commitment, and commitment blinding factor. + +#### `GenerateClawbackProof(privkey PrivateKey, pubkey PublicKey, ctxHash ContextHash, amount uint64, ciphertext Ciphertext) ([CompactClawbackProofSize]byte, error)` + +Generates the 64-byte compact sigma proof used by `ConfidentialMPTClawback`. It proves that the issuer-encrypted balance ciphertext contains the revealed clawback amount without exposing the issuer private key. + +#### `GenerateSendProof(privkey PrivateKey, pubkey PublicKey, amount uint64, participants []Participant, txBF BlindingFactor, ctxHash ContextHash, amountCommitment Commitment, balanceParams PedersenProofParams) ([]byte, error)` + +Generates the 946-byte `ConfidentialMPTSend` proof: + +- 192-byte compact sigma proof for ciphertext consistency, amount linkage, balance linkage, and sender key ownership +- 754-byte aggregated range proof for the transfer amount and post-send balance + +The inputs have the following relationships: + +- `privkey` and `pubkey` are the sender's keypair. +- `participants` follows the sender, destination, issuer, optional-auditor order described above. +- Every participant ciphertext encrypts `amount` with `txBF`. +- `amountCommitment` must be the commitment returned by `PedersenCommitment(amount, txBF)`; it intentionally reuses the ElGamal randomness. +- `balanceParams` describes the sender's original spending balance and its commitment witness. + +The successful native call currently writes `SendProofSize` bytes. The slice return type mirrors the C API's output buffer plus output-length contract. + +### Top-level verification + +```go +func VerifyConvertProof( + proof [SchnorrProofSize]byte, + pubkey PublicKey, + ctxHash ContextHash, +) error + +func VerifyConvertBackProof( + proof [ConvertBackProofSize]byte, + pubkey PublicKey, + ciphertext Ciphertext, + balanceCommit Commitment, + amount uint64, + ctxHash ContextHash, +) error + +func VerifySendProof( + proof []byte, + participants []Participant, + senderCt Ciphertext, + amountCommit, balanceCommit Commitment, + ctxHash ContextHash, +) error + +func VerifyClawbackProof( + proof [CompactClawbackProofSize]byte, + amount uint64, + pubkey PublicKey, + ciphertext Ciphertext, + ctxHash ContextHash, +) error +``` + +Each verifier returns `nil` only when the native proof check succeeds. Additional input contracts: + +- `VerifyConvertBackProof` expects the original balance commitment. The native library subtracts `amount` before verifying the remainder range proof; do not pass a precomputed remainder commitment. +- `VerifySendProof` requires exactly `SendProofSize` proof bytes and the same ordered participant list used for generation. `senderCt` is the sender's original on-ledger spending-balance ciphertext, while the participant ciphertexts encrypt the transfer amount. +- `VerifySendProof` expects the original amount and balance commitments used to generate the proof. + +### Auxiliary verification + +#### `VerifyRevealedAmount(amount uint64, bf BlindingFactor, holder, issuer Participant, auditor *Participant) error` + +Checks deterministically that the holder, issuer, and optional auditor ciphertexts all encrypt the revealed `amount` using `bf`. This is a direct plaintext/ciphertext consistency check, not a ZK-proof verifier. Pass `nil` when no auditor ciphertext is required. + +#### `VerifySendRangeProof(proof [DoubleBulletproofSize]byte, amountCommit, balanceCommitment Commitment, ctxHash ContextHash) error` + +Verifies the 754-byte aggregated range-proof component from a send proof. `balanceCommitment` must be the sender's original balance commitment; the native library derives the post-send remainder from it and `amountCommit`. Do not pass a precomputed remainder commitment. + +## Error behavior + +The package exposes two sentinel errors: + +- `ErrCgoRequired`: the native backend is unavailable for the current build. +- `ErrInvalidAmountRange`: a native `DecryptAmount` call received invalid search bounds. + +Use `errors.Is` for these sentinels because range errors include bound details: + +```go +amount, err := mptcrypto.DecryptAmount(ciphertext, privateKey, low, high) +if errors.Is(err, mptcrypto.ErrCgoRequired) { + // Confidential cryptography is unavailable in this build. +} +if errors.Is(err, mptcrypto.ErrInvalidAmountRange) { + // Fix the caller-supplied range. +} +``` + +Other validation and native-library failures are returned as descriptive errors. Every native wrapper treats a non-zero C return code as failure. + +## Maintaining the cgo boundary + +`mptcrypto_cgo.go` contains the build constraint and per-platform linker flags. It passes fixed-size byte arrays to C through pointers to their first elements and copies Go compound values into their corresponding C structs field by field. Variable participant lists are copied into a contiguous slice of `C.mpt_confidential_participant` values before the native call. + +The native routines use these pointers only for the duration of the call; they must not retain Go memory after returning. Keep all `import "C"`, `unsafe`, C layout conversion, and native linker changes inside this package so the higher-level confidential packages remain portable pure Go code. diff --git a/confidential/mptcrypto/errors.go b/confidential/mptcrypto/errors.go new file mode 100644 index 000000000..eaddb8a1e --- /dev/null +++ b/confidential/mptcrypto/errors.go @@ -0,0 +1,13 @@ +package mptcrypto + +import "errors" + +var ( + // ErrCgoRequired is returned when native confidential MPT operations are unavailable. + ErrCgoRequired = errors.New( + "mptcrypto: CGo is required for confidential MPT operations; " + + "rebuild with CGO_ENABLED=1 and vendored mpt-crypto libraries", + ) + // ErrInvalidAmountRange is returned when a decryption search range is invalid. + ErrInvalidAmountRange = errors.New("mptcrypto: invalid amount range") +) diff --git a/confidential/mptcrypto/mptcrypto_cgo.go b/confidential/mptcrypto/mptcrypto_cgo.go new file mode 100644 index 000000000..b9c13a4d1 --- /dev/null +++ b/confidential/mptcrypto/mptcrypto_cgo.go @@ -0,0 +1,454 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package mptcrypto + +/* +#cgo CFLAGS: -I${SRCDIR}/../deps/include -I${SRCDIR}/../deps/include/utility +#cgo linux,amd64 LDFLAGS: -L${SRCDIR}/../deps/libs/linux-amd64 -lmpt-crypto -lsecp256k1 -lcrypto -lstdc++ -lz -lm -ldl -lpthread +#cgo linux,arm64 LDFLAGS: -L${SRCDIR}/../deps/libs/linux-arm64 -lmpt-crypto -lsecp256k1 -lcrypto -lstdc++ -lz -lm -ldl -lpthread +#cgo darwin,arm64 LDFLAGS: -L${SRCDIR}/../deps/libs/darwin-arm64 -lmpt-crypto -lsecp256k1 -lcrypto -lc++ -lz -lm +#cgo darwin,amd64 LDFLAGS: -L${SRCDIR}/../deps/libs/darwin-amd64 -lmpt-crypto -lsecp256k1 -lcrypto -lc++ -lz -lm + +#include "mpt_utility.h" +*/ +import "C" + +import ( + "fmt" + "math" + "unsafe" +) + +func uint8Ptr(p *byte) *C.uint8_t { + return (*C.uint8_t)(unsafe.Pointer(p)) +} + +// region C struct helpers +func toAccountID(id [AccountIDSize]byte) C.account_id { + var c C.account_id + for i, b := range id { + c.bytes[i] = C.uint8_t(b) + } + return c +} + +func toIssuanceID(id [IssuanceIDSize]byte) C.mpt_issuance_id { + var c C.mpt_issuance_id + for i, b := range id { + c.bytes[i] = C.uint8_t(b) + } + return c +} + +func toParticipant(p Participant) C.mpt_confidential_participant { + var c C.mpt_confidential_participant + for i, b := range p.PubKey { + c.pubkey[i] = C.uint8_t(b) + } + for i, b := range p.Ciphertext { + c.ciphertext[i] = C.uint8_t(b) + } + return c +} + +func toProofParams(p PedersenProofParams) C.mpt_pedersen_proof_params { + var c C.mpt_pedersen_proof_params + for i, b := range p.Commitment { + c.pedersen_commitment[i] = C.uint8_t(b) + } + c.amount = C.uint64_t(p.Amount) + for i, b := range p.Ciphertext { + c.ciphertext[i] = C.uint8_t(b) + } + for i, b := range p.BlindingFactor { + c.blinding_factor[i] = C.uint8_t(b) + } + return c +} + +// endregion + +// region ElGamal + +// GenerateKeypair creates a new secp256k1 ElGamal keypair. +// Returns a 32-byte private key and a 33-byte compressed public key. +func GenerateKeypair() (privkey PrivateKey, pubkey PublicKey, err error) { + ret := C.mpt_generate_keypair( + uint8Ptr(&privkey[0]), + uint8Ptr(&pubkey[0]), + ) + if ret != 0 { + return privkey, pubkey, fmt.Errorf("mpt_generate_keypair failed with code %d", ret) + } + return +} + +// GenerateBlindingFactor returns a random 32-byte scalar suitable for ElGamal encryption. +func GenerateBlindingFactor() (bf BlindingFactor, err error) { + ret := C.mpt_generate_blinding_factor( + uint8Ptr(&bf[0]), + ) + if ret != 0 { + return bf, fmt.Errorf("mpt_generate_blinding_factor failed with code %d", ret) + } + return +} + +// EncryptAmount encrypts a uint64 amount under a compressed public key using a blinding factor. +// Returns a 66-byte ciphertext (two compressed EC points: C1 || C2). +func EncryptAmount(amount uint64, pubkey PublicKey, bf BlindingFactor) (ct Ciphertext, err error) { + ret := C.mpt_encrypt_amount( + C.uint64_t(amount), + uint8Ptr(&pubkey[0]), + uint8Ptr(&bf[0]), + uint8Ptr(&ct[0]), + ) + if ret != 0 { + return ct, fmt.Errorf("mpt_encrypt_amount failed with code %d", ret) + } + return +} + +// DecryptAmount decrypts a 66-byte ElGamal ciphertext using a private key. +// It searches the inclusive [rangeLow, rangeHigh] interval with linear cost. +func DecryptAmount(ciphertext Ciphertext, privateKey PrivateKey, rangeLow, rangeHigh uint64) (uint64, error) { + if err := validateAmountRange(rangeLow, rangeHigh); err != nil { + return 0, err + } + + var amount C.uint64_t + ret := C.mpt_decrypt_amount( + uint8Ptr(&ciphertext[0]), + uint8Ptr(&privateKey[0]), + &amount, + C.uint64_t(rangeLow), + C.uint64_t(rangeHigh), + ) + if ret != 0 { + return 0, fmt.Errorf("mpt_decrypt_amount failed with code %d", ret) + } + return uint64(amount), nil +} + +func validateAmountRange(rangeLow, rangeHigh uint64) error { + if rangeLow > rangeHigh { + return fmt.Errorf("%w: low %d exceeds high %d", ErrInvalidAmountRange, rangeLow, rangeHigh) + } + if rangeHigh == math.MaxUint64 { + return fmt.Errorf("%w: high must be less than %d", ErrInvalidAmountRange, uint64(math.MaxUint64)) + } + return nil +} + +// endregion + +// region Context hashes + +// ConvertContextHash computes the context hash for a ConfidentialMPTConvert transaction. +func ConvertContextHash(account [AccountIDSize]byte, iss [IssuanceIDSize]byte, seq uint32) (hash ContextHash, err error) { + ret := C.mpt_get_convert_context_hash( + toAccountID(account), + toIssuanceID(iss), + C.uint32_t(seq), + uint8Ptr(&hash[0]), + ) + if ret != 0 { + return hash, fmt.Errorf("mpt_get_convert_context_hash failed with code %d", ret) + } + return +} + +// ConvertBackContextHash computes the context hash for a ConfidentialMPTConvertBack transaction. +func ConvertBackContextHash(account [AccountIDSize]byte, iss [IssuanceIDSize]byte, seq, ver uint32) (hash ContextHash, err error) { + ret := C.mpt_get_convert_back_context_hash( + toAccountID(account), + toIssuanceID(iss), + C.uint32_t(seq), + C.uint32_t(ver), + uint8Ptr(&hash[0]), + ) + if ret != 0 { + return hash, fmt.Errorf("mpt_get_convert_back_context_hash failed with code %d", ret) + } + return +} + +// SendContextHash computes the context hash for a ConfidentialMPTSend transaction. +func SendContextHash(account [AccountIDSize]byte, iss [IssuanceIDSize]byte, seq uint32, dest [AccountIDSize]byte, ver uint32) (hash ContextHash, err error) { + ret := C.mpt_get_send_context_hash( + toAccountID(account), + toIssuanceID(iss), + C.uint32_t(seq), + toAccountID(dest), + C.uint32_t(ver), + uint8Ptr(&hash[0]), + ) + if ret != 0 { + return hash, fmt.Errorf("mpt_get_send_context_hash failed with code %d", ret) + } + return +} + +// ClawbackContextHash computes the context hash for a ConfidentialMPTClawback transaction. +func ClawbackContextHash(account [AccountIDSize]byte, iss [IssuanceIDSize]byte, seq uint32, holder [AccountIDSize]byte) (hash ContextHash, err error) { + ret := C.mpt_get_clawback_context_hash( + toAccountID(account), + toIssuanceID(iss), + C.uint32_t(seq), + toAccountID(holder), + uint8Ptr(&hash[0]), + ) + if ret != 0 { + return hash, fmt.Errorf("mpt_get_clawback_context_hash failed with code %d", ret) + } + return +} + +// endregion + +// region Pedersen commitment + +// PedersenCommitment computes a Pedersen commitment for the given amount and blinding factor. +func PedersenCommitment(amount uint64, bf BlindingFactor) (commitment Commitment, err error) { + ret := C.mpt_get_pedersen_commitment( + C.uint64_t(amount), + uint8Ptr(&bf[0]), + uint8Ptr(&commitment[0]), + ) + if ret != 0 { + return commitment, fmt.Errorf("mpt_get_pedersen_commitment failed with code %d", ret) + } + return +} + +// endregion + +// region Proof generation + +// GenerateConvertProof generates a Schnorr proof of knowledge for a ConfidentialMPTConvert transaction. +func GenerateConvertProof(pubkey PublicKey, privkey PrivateKey, ctxHash ContextHash) (proof [SchnorrProofSize]byte, err error) { + ret := C.mpt_get_convert_proof( + uint8Ptr(&pubkey[0]), + uint8Ptr(&privkey[0]), + uint8Ptr(&ctxHash[0]), + uint8Ptr(&proof[0]), + ) + if ret != 0 { + return proof, fmt.Errorf("mpt_get_convert_proof failed with code %d", ret) + } + return +} + +// GenerateConvertBackProof generates a compact AND-composed sigma proof over the balance +// witness, followed by a single Bulletproof range proof over the remainder commitment, +// for a ConfidentialMPTConvertBack transaction. +func GenerateConvertBackProof(privkey PrivateKey, pubkey PublicKey, ctxHash ContextHash, amount uint64, params PedersenProofParams) (proof [ConvertBackProofSize]byte, err error) { + cParams := toProofParams(params) + ret := C.mpt_get_convert_back_proof( + uint8Ptr(&privkey[0]), + uint8Ptr(&pubkey[0]), + uint8Ptr(&ctxHash[0]), + C.uint64_t(amount), + &cParams, + uint8Ptr(&proof[0]), + ) + if ret != 0 { + return proof, fmt.Errorf("mpt_get_convert_back_proof failed with code %d", ret) + } + return +} + +// GenerateClawbackProof generates an equality proof for a ConfidentialMPTClawback transaction. +func GenerateClawbackProof(privkey PrivateKey, pubkey PublicKey, ctxHash ContextHash, amount uint64, ciphertext Ciphertext) (proof [CompactClawbackProofSize]byte, err error) { + ret := C.mpt_get_clawback_proof( + uint8Ptr(&privkey[0]), + uint8Ptr(&pubkey[0]), + uint8Ptr(&ctxHash[0]), + C.uint64_t(amount), + uint8Ptr(&ciphertext[0]), + uint8Ptr(&proof[0]), + ) + if ret != 0 { + return proof, fmt.Errorf("mpt_get_clawback_proof failed with code %d", ret) + } + return +} + +// GenerateSendProof generates a compact AND-composed sigma proof + aggregated Bulletproof range proof +// for a ConfidentialMPTSend transaction. +func GenerateSendProof(privkey PrivateKey, pubkey PublicKey, amount uint64, participants []Participant, txBF BlindingFactor, ctxHash ContextHash, amountCommitment Commitment, balanceParams PedersenProofParams) ([]byte, error) { + n := len(participants) + if n == 0 { + return nil, fmt.Errorf("mptcrypto: at least one participant is required") + } + if n > MaxParticipants { + return nil, fmt.Errorf("mptcrypto: too many participants: %d (max %d)", n, MaxParticipants) + } + proof := make([]byte, SendProofSize) + outLen := C.size_t(SendProofSize) + + cParts := make([]C.mpt_confidential_participant, n) + for i, p := range participants { + cParts[i] = toParticipant(p) + } + + cBalance := toProofParams(balanceParams) + + ret := C.mpt_get_confidential_send_proof( + uint8Ptr(&privkey[0]), + uint8Ptr(&pubkey[0]), + C.uint64_t(amount), + &cParts[0], + C.size_t(n), + uint8Ptr(&txBF[0]), + uint8Ptr(&ctxHash[0]), + uint8Ptr(&amountCommitment[0]), + &cBalance, + uint8Ptr(&proof[0]), + &outLen, + ) + if ret != 0 { + return nil, fmt.Errorf("mpt_get_confidential_send_proof failed with code %d", ret) + } + return proof[:outLen], nil +} + +// endregion + +// region Proof verification (top-level) + +// VerifyConvertProof verifies a Schnorr proof for a ConfidentialMPTConvert transaction. +func VerifyConvertProof(proof [SchnorrProofSize]byte, pubkey PublicKey, ctxHash ContextHash) error { + ret := C.mpt_verify_convert_proof( + uint8Ptr(&proof[0]), + uint8Ptr(&pubkey[0]), + uint8Ptr(&ctxHash[0]), + ) + if ret != 0 { + return fmt.Errorf("mpt_verify_convert_proof failed with code %d", ret) + } + return nil +} + +// VerifyConvertBackProof verifies a linkage + range proof for a ConfidentialMPTConvertBack transaction. +// balanceCommit must be the original balance commitment, not the remainder after subtraction, +// the C library internally subtracts the transparent amount before checking the range proof. +func VerifyConvertBackProof(proof [ConvertBackProofSize]byte, pubkey PublicKey, ciphertext Ciphertext, balanceCommit Commitment, amount uint64, ctxHash ContextHash) error { + ret := C.mpt_verify_convert_back_proof( + uint8Ptr(&proof[0]), + uint8Ptr(&pubkey[0]), + uint8Ptr(&ciphertext[0]), + uint8Ptr(&balanceCommit[0]), + C.uint64_t(amount), + uint8Ptr(&ctxHash[0]), + ) + if ret != 0 { + return fmt.Errorf("mpt_verify_convert_back_proof failed with code %d", ret) + } + return nil +} + +// VerifySendProof verifies the full proof for a ConfidentialMPTSend transaction. +func VerifySendProof(proof []byte, participants []Participant, senderCt Ciphertext, amountCommit, balanceCommit Commitment, ctxHash ContextHash) error { + if len(proof) != SendProofSize { + return fmt.Errorf("mptcrypto: proof must be %d bytes, got %d", SendProofSize, len(proof)) + } + if len(participants) == 0 { + return fmt.Errorf("mptcrypto: at least one participant is required") + } + if len(participants) > MaxParticipants { + return fmt.Errorf("mptcrypto: too many participants: %d (max %d)", len(participants), MaxParticipants) + } + cParts := make([]C.mpt_confidential_participant, len(participants)) + for i, p := range participants { + cParts[i] = toParticipant(p) + } + ret := C.mpt_verify_send_proof( + uint8Ptr(&proof[0]), + &cParts[0], + C.uint8_t(len(participants)), + uint8Ptr(&senderCt[0]), + uint8Ptr(&amountCommit[0]), + uint8Ptr(&balanceCommit[0]), + uint8Ptr(&ctxHash[0]), + ) + if ret != 0 { + return fmt.Errorf("mpt_verify_send_proof failed with code %d", ret) + } + return nil +} + +// VerifyClawbackProof verifies an equality proof for a ConfidentialMPTClawback transaction. +func VerifyClawbackProof(proof [CompactClawbackProofSize]byte, amount uint64, pubkey PublicKey, ciphertext Ciphertext, ctxHash ContextHash) error { + ret := C.mpt_verify_clawback_proof( + uint8Ptr(&proof[0]), + C.uint64_t(amount), + uint8Ptr(&pubkey[0]), + uint8Ptr(&ciphertext[0]), + uint8Ptr(&ctxHash[0]), + ) + if ret != 0 { + return fmt.Errorf("mpt_verify_clawback_proof failed with code %d", ret) + } + return nil +} + +// endregion + +// region Internal component verifiers + +// VerifyRevealedAmount verifies that a revealed amount and blinding factor are consistent +// with the participants' ciphertexts. auditor may be nil if no auditor is present. +func VerifyRevealedAmount(amount uint64, bf BlindingFactor, holder, issuer Participant, auditor *Participant) error { + cHolder := toParticipant(holder) + cIssuer := toParticipant(issuer) + var cAuditor *C.mpt_confidential_participant + if auditor != nil { + a := toParticipant(*auditor) + cAuditor = &a + } + ret := C.mpt_verify_revealed_amount( + C.uint64_t(amount), + uint8Ptr(&bf[0]), + &cHolder, + &cIssuer, + cAuditor, + ) + if ret != 0 { + return fmt.Errorf("mpt_verify_revealed_amount failed with code %d", ret) + } + return nil +} + +// VerifySendRangeProof verifies that the transfer amount and remaining balance are within [0, 2^64-1]. +func VerifySendRangeProof(proof [DoubleBulletproofSize]byte, amountCommit, balanceCommitment Commitment, ctxHash ContextHash) error { + ret := C.mpt_verify_send_range_proof( + uint8Ptr(&proof[0]), + uint8Ptr(&amountCommit[0]), + uint8Ptr(&balanceCommitment[0]), + uint8Ptr(&ctxHash[0]), + ) + if ret != 0 { + return fmt.Errorf("mpt_verify_send_range_proof failed with code %d", ret) + } + return nil +} + +// endregion + +// region Utilities + +// ComputeConvertBackRemainder subtracts a transparent amount from a hidden Pedersen commitment. +func ComputeConvertBackRemainder(commitmentIn Commitment, amount uint64) (commitmentOut Commitment, err error) { + ret := C.mpt_compute_convert_back_remainder( + uint8Ptr(&commitmentIn[0]), + C.uint64_t(amount), + uint8Ptr(&commitmentOut[0]), + ) + if ret != 0 { + return commitmentOut, fmt.Errorf("mpt_compute_convert_back_remainder failed with code %d", ret) + } + return +} + +// endregion diff --git a/confidential/mptcrypto/mptcrypto_nocgo.go b/confidential/mptcrypto/mptcrypto_nocgo.go new file mode 100644 index 000000000..ea6a1f23c --- /dev/null +++ b/confidential/mptcrypto/mptcrypto_nocgo.go @@ -0,0 +1,140 @@ +//go:build !cgo || js || wasip1 || tinygo || gofuzz || !(linux || darwin) || !(amd64 || arm64) + +package mptcrypto + +// region ElGamal + +// GenerateKeypair creates a new secp256k1 ElGamal keypair. +// Returns a 32-byte private key and a 33-byte compressed public key. +func GenerateKeypair() (privkey PrivateKey, pubkey PublicKey, err error) { + return privkey, pubkey, ErrCgoRequired +} + +// GenerateBlindingFactor returns a random 32-byte scalar suitable for ElGamal encryption. +func GenerateBlindingFactor() (bf BlindingFactor, err error) { + return bf, ErrCgoRequired +} + +// EncryptAmount encrypts a uint64 amount under a compressed public key using a blinding factor. +// Returns a 66-byte ciphertext (two compressed EC points: C1 || C2). +func EncryptAmount(amount uint64, pubkey PublicKey, bf BlindingFactor) (ct Ciphertext, err error) { + return ct, ErrCgoRequired +} + +// DecryptAmount decrypts a 66-byte ElGamal ciphertext using a private key. +// It searches the inclusive [rangeLow, rangeHigh] interval with linear cost. +func DecryptAmount(ciphertext Ciphertext, privateKey PrivateKey, rangeLow, rangeHigh uint64) (uint64, error) { + return 0, ErrCgoRequired +} + +// endregion + +// region Context hashes + +// ConvertContextHash computes the context hash for a ConfidentialMPTConvert transaction. +func ConvertContextHash(account [AccountIDSize]byte, iss [IssuanceIDSize]byte, seq uint32) (hash ContextHash, err error) { + return hash, ErrCgoRequired +} + +// ConvertBackContextHash computes the context hash for a ConfidentialMPTConvertBack transaction. +func ConvertBackContextHash(account [AccountIDSize]byte, iss [IssuanceIDSize]byte, seq, ver uint32) (hash ContextHash, err error) { + return hash, ErrCgoRequired +} + +// SendContextHash computes the context hash for a ConfidentialMPTSend transaction. +func SendContextHash(account [AccountIDSize]byte, iss [IssuanceIDSize]byte, seq uint32, dest [AccountIDSize]byte, ver uint32) (hash ContextHash, err error) { + return hash, ErrCgoRequired +} + +// ClawbackContextHash computes the context hash for a ConfidentialMPTClawback transaction. +func ClawbackContextHash(account [AccountIDSize]byte, iss [IssuanceIDSize]byte, seq uint32, holder [AccountIDSize]byte) (hash ContextHash, err error) { + return hash, ErrCgoRequired +} + +// endregion + +// region Pedersen commitment + +// PedersenCommitment computes a Pedersen commitment for the given amount and blinding factor. +func PedersenCommitment(amount uint64, bf BlindingFactor) (commitment Commitment, err error) { + return commitment, ErrCgoRequired +} + +// endregion + +// region Proof generation + +// GenerateConvertProof generates a Schnorr proof of knowledge for a ConfidentialMPTConvert transaction. +func GenerateConvertProof(pubkey PublicKey, privkey PrivateKey, ctxHash ContextHash) (proof [SchnorrProofSize]byte, err error) { + return proof, ErrCgoRequired +} + +// GenerateConvertBackProof generates a compact AND-composed sigma proof over the balance +// witness, followed by a single Bulletproof range proof over the remainder commitment, +// for a ConfidentialMPTConvertBack transaction. +func GenerateConvertBackProof(privkey PrivateKey, pubkey PublicKey, ctxHash ContextHash, amount uint64, params PedersenProofParams) (proof [ConvertBackProofSize]byte, err error) { + return proof, ErrCgoRequired +} + +// GenerateClawbackProof generates an equality proof for a ConfidentialMPTClawback transaction. +func GenerateClawbackProof(privkey PrivateKey, pubkey PublicKey, ctxHash ContextHash, amount uint64, ciphertext Ciphertext) (proof [CompactClawbackProofSize]byte, err error) { + return proof, ErrCgoRequired +} + +// GenerateSendProof generates a compact AND-composed sigma proof + aggregated Bulletproof range proof +// for a ConfidentialMPTSend transaction. +func GenerateSendProof(privkey PrivateKey, pubkey PublicKey, amount uint64, participants []Participant, txBF BlindingFactor, ctxHash ContextHash, amountCommitment Commitment, balanceParams PedersenProofParams) ([]byte, error) { + return nil, ErrCgoRequired +} + +// endregion + +// region Proof verification (top-level) + +// VerifyConvertProof verifies a Schnorr proof for a ConfidentialMPTConvert transaction. +func VerifyConvertProof(proof [SchnorrProofSize]byte, pubkey PublicKey, ctxHash ContextHash) error { + return ErrCgoRequired +} + +// VerifyConvertBackProof verifies a linkage + range proof for a ConfidentialMPTConvertBack transaction. +// balanceCommit must be the original balance commitment, not the remainder after subtraction, +// the C library internally subtracts the transparent amount before checking the range proof. +func VerifyConvertBackProof(proof [ConvertBackProofSize]byte, pubkey PublicKey, ciphertext Ciphertext, balanceCommit Commitment, amount uint64, ctxHash ContextHash) error { + return ErrCgoRequired +} + +// VerifySendProof verifies the full proof for a ConfidentialMPTSend transaction. +func VerifySendProof(proof []byte, participants []Participant, senderCt Ciphertext, amountCommit, balanceCommit Commitment, ctxHash ContextHash) error { + return ErrCgoRequired +} + +// VerifyClawbackProof verifies an equality proof for a ConfidentialMPTClawback transaction. +func VerifyClawbackProof(proof [CompactClawbackProofSize]byte, amount uint64, pubkey PublicKey, ciphertext Ciphertext, ctxHash ContextHash) error { + return ErrCgoRequired +} + +// endregion + +// region Internal component verifiers + +// VerifyRevealedAmount verifies that a revealed amount and blinding factor are consistent +// with the participants' ciphertexts. auditor may be nil if no auditor is present. +func VerifyRevealedAmount(amount uint64, bf BlindingFactor, holder, issuer Participant, auditor *Participant) error { + return ErrCgoRequired +} + +// VerifySendRangeProof verifies that the transfer amount and remaining balance are within [0, 2^64-1]. +func VerifySendRangeProof(proof [DoubleBulletproofSize]byte, amountCommit, balanceCommitment Commitment, ctxHash ContextHash) error { + return ErrCgoRequired +} + +// endregion + +// region Utilities + +// ComputeConvertBackRemainder subtracts a transparent amount from a hidden Pedersen commitment. +func ComputeConvertBackRemainder(commitmentIn Commitment, amount uint64) (commitmentOut Commitment, err error) { + return commitmentOut, ErrCgoRequired +} + +// endregion diff --git a/confidential/mptcrypto/mptcrypto_nocgo_test.go b/confidential/mptcrypto/mptcrypto_nocgo_test.go new file mode 100644 index 000000000..a25ec341d --- /dev/null +++ b/confidential/mptcrypto/mptcrypto_nocgo_test.go @@ -0,0 +1,15 @@ +//go:build !cgo || js || wasip1 || tinygo || gofuzz || !(linux || darwin) || !(amd64 || arm64) + +package mptcrypto_test + +import ( + "testing" + + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/stretchr/testify/require" +) + +func TestDecryptAmountWithoutCgo(t *testing.T) { + _, err := mptcrypto.DecryptAmount(mptcrypto.Ciphertext{}, mptcrypto.PrivateKey{}, 2, 1) + require.ErrorIs(t, err, mptcrypto.ErrCgoRequired) +} diff --git a/confidential/mptcrypto/mptcrypto_test.go b/confidential/mptcrypto/mptcrypto_test.go new file mode 100644 index 000000000..f06876666 --- /dev/null +++ b/confidential/mptcrypto/mptcrypto_test.go @@ -0,0 +1,463 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package mptcrypto_test + +import ( + "fmt" + "math" + "testing" + + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/stretchr/testify/require" +) + +// testAccountID returns a deterministic 20-byte account ID for testing. +func testAccountID(seed byte) [mptcrypto.AccountIDSize]byte { + var id [mptcrypto.AccountIDSize]byte + for i := range id { + id[i] = seed + byte(i) + } + return id +} + +// testIssuanceID returns a deterministic 24-byte issuance ID for testing. +func testIssuanceID() [mptcrypto.IssuanceIDSize]byte { + var id [mptcrypto.IssuanceIDSize]byte + for i := range id { + id[i] = byte(i + 0x10) + } + return id +} + +// region ElGamal +func TestGenerateKeypair(t *testing.T) { + priv, pub, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + require.NotEqual(t, mptcrypto.PrivateKey{}, priv, "privkey is all zeros") + // compressed secp256k1 pubkey starts with 0x02 or 0x03 + require.Contains(t, []byte{0x02, 0x03}, pub[0], "unexpected pubkey prefix: 0x%02x", pub[0]) +} + +func TestGenerateBlindingFactor(t *testing.T) { + bf1, err := mptcrypto.GenerateBlindingFactor() + require.NoError(t, err) + require.NotEqual(t, mptcrypto.BlindingFactor{}, bf1, "blinding factor is all zeros") + + // two calls should produce different values (non-deterministic RNG) + bf2, err := mptcrypto.GenerateBlindingFactor() + require.NoError(t, err) + require.NotEqual(t, bf1, bf2, "two consecutive blinding factors are identical") +} + +func TestDecryptAmountBounds(t *testing.T) { + // amount is an arbitrary non-boundary value used to exercise the range checks. + const amount uint64 = 42 + + privateKey, publicKey, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + blindingFactor, err := mptcrypto.GenerateBlindingFactor() + require.NoError(t, err) + ciphertext, err := mptcrypto.EncryptAmount(amount, publicKey, blindingFactor) + require.NoError(t, err) + + tests := []struct { + name string + rangeLow uint64 + rangeHigh uint64 + wantErr bool + }{ + {name: "pass - found at lower bound", rangeLow: amount, rangeHigh: 50}, + {name: "pass - found at upper bound", rangeLow: 40, rangeHigh: amount}, + {name: "pass - found inside range", rangeLow: 40, rangeHigh: 50}, + {name: "pass - single-value interval", rangeLow: amount, rangeHigh: amount}, + {name: "fail - amount below range", rangeLow: 43, rangeHigh: 50, wantErr: true}, + {name: "fail - amount above range", rangeLow: 0, rangeHigh: 41, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := mptcrypto.DecryptAmount(ciphertext, privateKey, tt.rangeLow, tt.rangeHigh) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, amount, got) + }) + } +} + +func TestDecryptAmountInvalidRange(t *testing.T) { + tests := []struct { + name string + rangeLow uint64 + rangeHigh uint64 + }{ + {name: "fail - low exceeds high", rangeLow: 2, rangeHigh: 1}, + {name: "fail - high is max uint64", rangeLow: 0, rangeHigh: math.MaxUint64}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := mptcrypto.DecryptAmount(mptcrypto.Ciphertext{}, mptcrypto.PrivateKey{}, tt.rangeLow, tt.rangeHigh) + require.ErrorIs(t, err, mptcrypto.ErrInvalidAmountRange) + }) + } +} + +// endregion + +// region Context hashes +type contextHashFn func() (mptcrypto.ContextHash, error) + +func TestContextHashes(t *testing.T) { + account := testAccountID(0x01) + account2 := testAccountID(0x20) + iss := testIssuanceID() + + tests := []struct { + name string + hash contextHashFn + other contextHashFn + }{ + { + "pass - Convert", + func() (mptcrypto.ContextHash, error) { return mptcrypto.ConvertContextHash(account, iss, 1) }, + func() (mptcrypto.ContextHash, error) { return mptcrypto.ConvertContextHash(account, iss, 2) }, + }, + { + "pass - ConvertBack", + func() (mptcrypto.ContextHash, error) { + return mptcrypto.ConvertBackContextHash(account, iss, 1, 1) + }, + func() (mptcrypto.ContextHash, error) { + return mptcrypto.ConvertBackContextHash(account, iss, 1, 2) + }, + }, + { + "pass - Send", + func() (mptcrypto.ContextHash, error) { + return mptcrypto.SendContextHash(account, iss, 1, account2, 1) + }, + func() (mptcrypto.ContextHash, error) { + return mptcrypto.SendContextHash(account, iss, 1, testAccountID(0x30), 1) + }, + }, + { + "pass - Clawback", + func() (mptcrypto.ContextHash, error) { + return mptcrypto.ClawbackContextHash(account, iss, 1, account2) + }, + func() (mptcrypto.ContextHash, error) { + return mptcrypto.ClawbackContextHash(account, iss, 1, testAccountID(0x30)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash, err := tt.hash() + require.NoError(t, err) + require.NotEqual(t, mptcrypto.ContextHash{}, hash) + + // deterministic + hash2, err := tt.hash() + require.NoError(t, err) + require.Equal(t, hash, hash2) + + // different input -> different hash + hash3, err := tt.other() + require.NoError(t, err) + require.NotEqual(t, hash, hash3) + }) + } +} + +// endregion + +// region Pedersen commitment +func TestPedersenCommitment(t *testing.T) { + bf, err := mptcrypto.GenerateBlindingFactor() + require.NoError(t, err) + + tests := []struct { + name string + first uint64 + second uint64 + wantEqual bool + }{ + {name: "pass - same inputs are deterministic", first: 42, second: 42, wantEqual: true}, + {name: "pass - different amounts", first: 42, second: 99}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + first, err := mptcrypto.PedersenCommitment(tt.first, bf) + require.NoError(t, err) + require.Contains(t, []byte{0x02, 0x03}, first[0], "unexpected commitment prefix: 0x%02x", first[0]) + + second, err := mptcrypto.PedersenCommitment(tt.second, bf) + require.NoError(t, err) + if tt.wantEqual { + require.Equal(t, first, second) + return + } + require.NotEqual(t, first, second) + }) + } +} + +// endregion + +// region Proof generation +func TestConvertProofRoundtrip(t *testing.T) { + priv, pub, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + _, wrongPub, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + + ctxHash, err := mptcrypto.ConvertContextHash(testAccountID(0x01), testIssuanceID(), 1) + require.NoError(t, err) + + proof, err := mptcrypto.GenerateConvertProof(pub, priv, ctxHash) + require.NoError(t, err) + require.NotEqual(t, [mptcrypto.SchnorrProofSize]byte{}, proof) + + tests := []struct { + name string + pubKey mptcrypto.PublicKey + wantErr bool + }{ + {name: "pass - valid proof verifies", pubKey: pub}, + {name: "fail - wrong key rejected", pubKey: wrongPub, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := mptcrypto.VerifyConvertProof(proof, tt.pubKey, ctxHash) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestConvertBackProofRoundtrip(t *testing.T) { + priv, pub, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + + totalAmount := uint64(100) + bf, err := mptcrypto.GenerateBlindingFactor() + require.NoError(t, err) + + ct, err := mptcrypto.EncryptAmount(totalAmount, pub, bf) + require.NoError(t, err) + + totalCommit, err := mptcrypto.PedersenCommitment(totalAmount, bf) + require.NoError(t, err) + + convertBackAmount := uint64(30) + + ctxHash, err := mptcrypto.ConvertBackContextHash(testAccountID(0x01), testIssuanceID(), 1, 1) + require.NoError(t, err) + + balanceParams := mptcrypto.PedersenProofParams{ + Commitment: totalCommit, + Amount: totalAmount, + Ciphertext: ct, + BlindingFactor: bf, + } + + proof, err := mptcrypto.GenerateConvertBackProof(priv, pub, ctxHash, convertBackAmount, balanceParams) + require.NoError(t, err) + + require.NotEqual(t, [mptcrypto.ConvertBackProofSize]byte{}, proof) + err = mptcrypto.VerifyConvertBackProof(proof, pub, ct, totalCommit, convertBackAmount, ctxHash) + require.NoError(t, err) +} + +func TestClawbackProofRoundtrip(t *testing.T) { + priv, pub, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + + amount := uint64(42) + bf, err := mptcrypto.GenerateBlindingFactor() + require.NoError(t, err) + + ct, err := mptcrypto.EncryptAmount(amount, pub, bf) + require.NoError(t, err) + + ctxHash, err := mptcrypto.ClawbackContextHash(testAccountID(0x01), testIssuanceID(), 1, testAccountID(0x20)) + require.NoError(t, err) + + proof, err := mptcrypto.GenerateClawbackProof(priv, pub, ctxHash, amount, ct) + require.NoError(t, err) + + require.NotEqual(t, [mptcrypto.CompactClawbackProofSize]byte{}, proof) + err = mptcrypto.VerifyClawbackProof(proof, amount, pub, ct, ctxHash) + require.NoError(t, err) +} + +func TestSendProofRoundtrip(t *testing.T) { + tests := []struct { + name string + withAuditor bool + }{ + {"pass - 3 participants", false}, + {"pass - 4 participants with auditor", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + senderPriv, senderPub, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + _, destPub, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + _, issuerPub, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + + balanceAmount := uint64(100) + balanceBF, err := mptcrypto.GenerateBlindingFactor() + require.NoError(t, err) + senderBalanceCT, err := mptcrypto.EncryptAmount(balanceAmount, senderPub, balanceBF) + require.NoError(t, err) + balanceCommit, err := mptcrypto.PedersenCommitment(balanceAmount, balanceBF) + require.NoError(t, err) + + sendAmount := uint64(30) + txBF, err := mptcrypto.GenerateBlindingFactor() + require.NoError(t, err) + + senderAmountCT, err := mptcrypto.EncryptAmount(sendAmount, senderPub, txBF) + require.NoError(t, err) + destAmountCT, err := mptcrypto.EncryptAmount(sendAmount, destPub, txBF) + require.NoError(t, err) + issuerAmountCT, err := mptcrypto.EncryptAmount(sendAmount, issuerPub, txBF) + require.NoError(t, err) + + participants := []mptcrypto.Participant{ + {PubKey: senderPub, Ciphertext: senderAmountCT}, + {PubKey: destPub, Ciphertext: destAmountCT}, + {PubKey: issuerPub, Ciphertext: issuerAmountCT}, + } + + if tt.withAuditor { + _, auditorPub, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + auditorAmountCT, err := mptcrypto.EncryptAmount(sendAmount, auditorPub, txBF) + require.NoError(t, err) + participants = append(participants, mptcrypto.Participant{ + PubKey: auditorPub, Ciphertext: auditorAmountCT, + }) + } + + amountCommit, err := mptcrypto.PedersenCommitment(sendAmount, txBF) + require.NoError(t, err) + + amountParams := mptcrypto.PedersenProofParams{ + Commitment: amountCommit, + Amount: sendAmount, + Ciphertext: senderAmountCT, + BlindingFactor: txBF, + } + balanceParams := mptcrypto.PedersenProofParams{ + Commitment: balanceCommit, + Amount: balanceAmount, + Ciphertext: senderBalanceCT, + BlindingFactor: balanceBF, + } + + ctxHash, err := mptcrypto.SendContextHash(testAccountID(0x01), testIssuanceID(), 1, testAccountID(0x20), 1) + require.NoError(t, err) + + proof, err := mptcrypto.GenerateSendProof(senderPriv, senderPub, sendAmount, participants, txBF, ctxHash, amountParams.Commitment, balanceParams) + require.NoError(t, err) + require.NotEmpty(t, proof) + + err = mptcrypto.VerifySendProof(proof, participants, senderBalanceCT, amountCommit, balanceCommit, ctxHash) + require.NoError(t, err) + }) + } +} + +func TestVerifySendProofRejectsShortProof(t *testing.T) { + shortProof := make([]byte, mptcrypto.SendProofSize-1) + + var senderCT mptcrypto.Ciphertext + var amountCommit, balanceCommit mptcrypto.Commitment + var ctxHash mptcrypto.ContextHash + + err := mptcrypto.VerifySendProof(shortProof, nil, senderCT, amountCommit, balanceCommit, ctxHash) + require.EqualError(t, err, fmt.Sprintf("mptcrypto: proof must be %d bytes, got %d", mptcrypto.SendProofSize, len(shortProof))) +} + +// endregion + +// region Internal component verifiers +func TestVerifyRevealedAmount(t *testing.T) { + const amount uint64 = 42 + + bf, err := mptcrypto.GenerateBlindingFactor() + require.NoError(t, err) + + _, holderPub, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + _, issuerPub, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + _, auditorPub, err := mptcrypto.GenerateKeypair() + require.NoError(t, err) + + holderCT, err := mptcrypto.EncryptAmount(amount, holderPub, bf) + require.NoError(t, err) + issuerCT, err := mptcrypto.EncryptAmount(amount, issuerPub, bf) + require.NoError(t, err) + auditorCT, err := mptcrypto.EncryptAmount(amount, auditorPub, bf) + require.NoError(t, err) + + holder := mptcrypto.Participant{PubKey: holderPub, Ciphertext: holderCT} + issuer := mptcrypto.Participant{PubKey: issuerPub, Ciphertext: issuerCT} + auditor := &mptcrypto.Participant{PubKey: auditorPub, Ciphertext: auditorCT} + + tests := []struct { + name string + verifyAmount uint64 + auditor *mptcrypto.Participant + wantErr bool + }{ + {name: "pass - without auditor", verifyAmount: amount}, + {name: "pass - with auditor", verifyAmount: amount, auditor: auditor}, + {name: "fail - wrong amount", verifyAmount: 99, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := mptcrypto.VerifyRevealedAmount(tt.verifyAmount, bf, holder, issuer, tt.auditor) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +// endregion + +// region Utilities + +func TestComputeConvertBackRemainder(t *testing.T) { + bf, err := mptcrypto.GenerateBlindingFactor() + require.NoError(t, err) + + commit, err := mptcrypto.PedersenCommitment(100, bf) + require.NoError(t, err) + + remainder, err := mptcrypto.ComputeConvertBackRemainder(commit, 30) + require.NoError(t, err) + require.Contains(t, []byte{0x02, 0x03}, remainder[0], "unexpected prefix: 0x%02x", remainder[0]) + require.NotEqual(t, commit, remainder) +} + +// endregion diff --git a/confidential/mptcrypto/types.go b/confidential/mptcrypto/types.go new file mode 100644 index 000000000..659bc844e --- /dev/null +++ b/confidential/mptcrypto/types.go @@ -0,0 +1,62 @@ +// Package mptcrypto provides CGo bindings to the XRPLF/mpt-crypto C library +// for XLS-96 Confidential MPT Transfers: ElGamal encryption, ZK proofs, +// Pedersen commitments, and context hash computation. +package mptcrypto + +import "math" + +// Size constants (bytes), matching mpt_utility.h defines. +const ( + PrivKeySize = 32 + PubKeySize = 33 + BlindingFactorSize = 32 + CiphertextSize = 66 // two compressed EC points (C1 || C2) + + AccountIDSize = 20 + IssuanceIDSize = 24 + HashOutputSize = 32 // kMPT_HALF_SHA_SIZE -- output size of context hash functions + CommitmentSize = 33 // compressed Pedersen commitment point + + SchnorrProofSize = 64 + SingleBulletproofSize = 688 + DoubleBulletproofSize = 754 + CompactClawbackProofSize = 64 + CompactConvertBackProofSize = 128 + CompactSendProofSize = 192 + ConvertBackProofSize = CompactConvertBackProofSize + SingleBulletproofSize + SendProofSize = CompactSendProofSize + DoubleBulletproofSize + + MaxParticipants = math.MaxUint8 // C API uses uint8_t for participant count +) + +// PrivateKey is a fixed-size ElGamal private key. +type PrivateKey [PrivKeySize]byte + +// PublicKey is a fixed-size compressed ElGamal public key. +type PublicKey [PubKeySize]byte + +// BlindingFactor is a fixed-size scalar used for encryption and commitments. +type BlindingFactor [BlindingFactorSize]byte + +// Ciphertext is a fixed-size ElGamal ciphertext. +type Ciphertext [CiphertextSize]byte + +// Commitment is a fixed-size compressed Pedersen commitment. +type Commitment [CommitmentSize]byte + +// ContextHash binds a proof to its transaction context. +type ContextHash [HashOutputSize]byte + +// Participant represents a party in a Confidential Send transaction. +type Participant struct { + PubKey PublicKey + Ciphertext Ciphertext +} + +// PedersenProofParams holds the parameters required to generate a Pedersen linkage proof. +type PedersenProofParams struct { + Commitment Commitment + Amount uint64 + Ciphertext Ciphertext + BlindingFactor BlindingFactor +} diff --git a/confidential/proof/clawback.go b/confidential/proof/clawback.go new file mode 100644 index 000000000..080ff1c41 --- /dev/null +++ b/confidential/proof/clawback.go @@ -0,0 +1,72 @@ +package proof + +import ( + "encoding/hex" + "fmt" + + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/Peersyst/xrpl-go/pkg/hexutil" +) + +// GenerateClawbackProof generates a compact sigma proof for a ConfidentialMPTClawback transaction. +// Returns 128 hex chars (64-byte proof). +func GenerateClawbackProof(privkeyHex, pubkeyHex, ctxHashHex string, amount uint64, ciphertextHex string) (string, error) { + privBytes, err := hexutil.DecodeFixedHex(privkeyHex, mptcrypto.PrivKeySize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidPrivKey, err) + } + pubBytes, err := hexutil.DecodeFixedHex(pubkeyHex, mptcrypto.PubKeySize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidPubKey, err) + } + hashBytes, err := hexutil.DecodeFixedHex(ctxHashHex, mptcrypto.HashOutputSize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidContextHash, err) + } + ctBytes, err := hexutil.DecodeFixedHex(ciphertextHex, mptcrypto.CiphertextSize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidCiphertext, err) + } + + priv := mptcrypto.PrivateKey(privBytes) + pub := mptcrypto.PublicKey(pubBytes) + hash := mptcrypto.ContextHash(hashBytes) + ct := mptcrypto.Ciphertext(ctBytes) + + proof, err := mptcrypto.GenerateClawbackProof(priv, pub, hash, amount, ct) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrProofGenerationFailed, err) + } + return hex.EncodeToString(proof[:]), nil +} + +// VerifyClawbackProof verifies an equality proof for a ConfidentialMPTClawback transaction. +func VerifyClawbackProof(proofHex string, amount uint64, pubkeyHex, ciphertextHex, ctxHashHex string) error { + proofBytes, err := hexutil.DecodeFixedHex(proofHex, mptcrypto.CompactClawbackProofSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidProof, err) + } + pubBytes, err := hexutil.DecodeFixedHex(pubkeyHex, mptcrypto.PubKeySize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidPubKey, err) + } + ctBytes, err := hexutil.DecodeFixedHex(ciphertextHex, mptcrypto.CiphertextSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidCiphertext, err) + } + hashBytes, err := hexutil.DecodeFixedHex(ctxHashHex, mptcrypto.HashOutputSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidContextHash, err) + } + + var proof [mptcrypto.CompactClawbackProofSize]byte + copy(proof[:], proofBytes) + pub := mptcrypto.PublicKey(pubBytes) + ct := mptcrypto.Ciphertext(ctBytes) + hash := mptcrypto.ContextHash(hashBytes) + + if err := mptcrypto.VerifyClawbackProof(proof, amount, pub, ct, hash); err != nil { + return fmt.Errorf("%w: %w", ErrProofVerificationFailed, err) + } + return nil +} diff --git a/confidential/proof/clawback_test.go b/confidential/proof/clawback_test.go new file mode 100644 index 000000000..d6427665a --- /dev/null +++ b/confidential/proof/clawback_test.go @@ -0,0 +1,96 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package proof_test + +import ( + "testing" + + "github.com/Peersyst/xrpl-go/confidential/elgamal" + "github.com/Peersyst/xrpl-go/confidential/proof" + "github.com/stretchr/testify/require" +) + +func TestGenerateAndVerifyClawbackProof(t *testing.T) { + const clawbackAmount uint64 = 500 + + issuerKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + + issuerBalanceCt, err := elgamal.Encrypt(clawbackAmount, issuerKP.PubKeyHex, bf) + require.NoError(t, err) + + ctxHash, err := proof.ClawbackContextHash(testAccount, testIssuanceID, 1, testHolder) + require.NoError(t, err) + + proofHex, err := proof.GenerateClawbackProof(issuerKP.PrivKeyHex, issuerKP.PubKeyHex, ctxHash, clawbackAmount, issuerBalanceCt) + require.NoError(t, err) + require.NotEmpty(t, proofHex) + + tests := []struct { + name string + verifyAmount uint64 + wantErr error + }{ + { + name: "correct amount", + verifyAmount: clawbackAmount, + }, + { + name: "wrong amount", + verifyAmount: 999, + wantErr: proof.ErrProofVerificationFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := proof.VerifyClawbackProof(proofHex, tt.verifyAmount, issuerKP.PubKeyHex, issuerBalanceCt, ctxHash) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestClawbackProofInvalidInputs(t *testing.T) { + kp, _ := elgamal.GenerateKeypair() + ctxHash, _ := proof.ClawbackContextHash(testAccount, testIssuanceID, 1, testHolder) + + tests := []struct { + name string + fn func() error + wantErr error + }{ + { + name: "fail - generate bad privkey", + fn: func() error { + _, err := proof.GenerateClawbackProof("zz", kp.PubKeyHex, ctxHash, 100, zeroHex(66)) + return err + }, + wantErr: proof.ErrInvalidPrivKey, + }, + { + name: "fail - generate bad ciphertext", + fn: func() error { + _, err := proof.GenerateClawbackProof(kp.PrivKeyHex, kp.PubKeyHex, ctxHash, 100, "bad") + return err + }, + wantErr: proof.ErrInvalidCiphertext, + }, + { + name: "fail - verify bad proof", + fn: func() error { + return proof.VerifyClawbackProof("0102", 100, kp.PubKeyHex, zeroHex(66), ctxHash) + }, + wantErr: proof.ErrInvalidProof, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.fn() + require.ErrorIs(t, err, tt.wantErr) + }) + } +} diff --git a/confidential/proof/context.go b/confidential/proof/context.go new file mode 100644 index 000000000..6786ce3b3 --- /dev/null +++ b/confidential/proof/context.go @@ -0,0 +1,99 @@ +// Package proof provides a hex-string API for ZK proof generation and verification. +// It wraps the byte-array functions in mptcrypto with hex encoding/decoding and +// classic XRPL address decoding for use with transaction fields. +package proof + +import ( + "encoding/hex" + "fmt" + + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" +) + +// ConvertContextHash computes the context hash for a ConfidentialMPTConvert transaction. +// account is a classic XRPL address, issuanceIDHex is 48 hex chars (24 bytes). +// Returns 64 hex chars (32 bytes). +func ConvertContextHash(account string, issuanceIDHex string, seq uint32) (string, error) { + accID, err := decodeAddress(account) + if err != nil { + return "", err + } + issID, err := decodeIssuanceID(issuanceIDHex) + if err != nil { + return "", err + } + + hash, err := mptcrypto.ConvertContextHash(accID, issID, seq) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrContextHashFailed, err) + } + return hex.EncodeToString(hash[:]), nil +} + +// ConvertBackContextHash computes the context hash for a ConfidentialMPTConvertBack transaction. +// account is a classic XRPL address, issuanceIDHex is 48 hex chars (24 bytes). +// Returns 64 hex chars (32 bytes). +func ConvertBackContextHash(account string, issuanceIDHex string, seq, version uint32) (string, error) { + accID, err := decodeAddress(account) + if err != nil { + return "", err + } + issID, err := decodeIssuanceID(issuanceIDHex) + if err != nil { + return "", err + } + + hash, err := mptcrypto.ConvertBackContextHash(accID, issID, seq, version) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrContextHashFailed, err) + } + return hex.EncodeToString(hash[:]), nil +} + +// SendContextHash computes the context hash for a ConfidentialMPTSend transaction. +// account and dest are classic XRPL addresses, issuanceIDHex is 48 hex chars (24 bytes). +// Returns 64 hex chars (32 bytes). +func SendContextHash(account string, issuanceIDHex string, seq uint32, dest string, version uint32) (string, error) { + accID, err := decodeAddress(account) + if err != nil { + return "", err + } + issID, err := decodeIssuanceID(issuanceIDHex) + if err != nil { + return "", err + } + destID, err := decodeAddress(dest) + if err != nil { + return "", err + } + + hash, err := mptcrypto.SendContextHash(accID, issID, seq, destID, version) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrContextHashFailed, err) + } + return hex.EncodeToString(hash[:]), nil +} + +// ClawbackContextHash computes the context hash for a ConfidentialMPTClawback transaction. +// account and holder are classic XRPL addresses, issuanceIDHex is 48 hex chars (24 bytes). +// Returns 64 hex chars (32 bytes). +func ClawbackContextHash(account string, issuanceIDHex string, seq uint32, holder string) (string, error) { + accID, err := decodeAddress(account) + if err != nil { + return "", err + } + issID, err := decodeIssuanceID(issuanceIDHex) + if err != nil { + return "", err + } + holderID, err := decodeAddress(holder) + if err != nil { + return "", err + } + + hash, err := mptcrypto.ClawbackContextHash(accID, issID, seq, holderID) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrContextHashFailed, err) + } + return hex.EncodeToString(hash[:]), nil +} diff --git a/confidential/proof/context_test.go b/confidential/proof/context_test.go new file mode 100644 index 000000000..0f0449c6f --- /dev/null +++ b/confidential/proof/context_test.go @@ -0,0 +1,131 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package proof_test + +import ( + "testing" + + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/Peersyst/xrpl-go/confidential/proof" + "github.com/stretchr/testify/require" +) + +func TestConvertContextHash(t *testing.T) { + tests := []struct { + name string + account string + issID string + seq uint32 + wantErr error + }{ + {"pass - valid inputs", testAccount, testIssuanceID, 1, nil}, + {"fail - invalid address", "notAnAddress", testIssuanceID, 1, proof.ErrInvalidAddress}, + {"fail - invalid issuance ID", testAccount, "zz", 1, proof.ErrInvalidIssuanceID}, + {"fail - short issuance ID", testAccount, "0102", 1, proof.ErrInvalidIssuanceID}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash, err := proof.ConvertContextHash(tt.account, tt.issID, tt.seq) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Len(t, hash, mptcrypto.HashOutputSize*2) + }) + } +} + +func TestConvertContextHashDeterministic(t *testing.T) { + h1, err := proof.ConvertContextHash(testAccount, testIssuanceID, 1) + require.NoError(t, err) + + h2, err := proof.ConvertContextHash(testAccount, testIssuanceID, 1) + require.NoError(t, err) + + require.Equal(t, h1, h2, "same inputs should produce the same hash") +} + +func TestConvertBackContextHash(t *testing.T) { + tests := []struct { + name string + account string + issID string + seq uint32 + ver uint32 + wantErr error + }{ + {"pass - valid inputs", testAccount, testIssuanceID, 1, 0, nil}, + {"fail - invalid address", "bad", testIssuanceID, 1, 0, proof.ErrInvalidAddress}, + {"fail - invalid issuance ID", testAccount, "bad", 1, 0, proof.ErrInvalidIssuanceID}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash, err := proof.ConvertBackContextHash(tt.account, tt.issID, tt.seq, tt.ver) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Len(t, hash, mptcrypto.HashOutputSize*2) + }) + } +} + +func TestSendContextHash(t *testing.T) { + tests := []struct { + name string + account string + issID string + seq uint32 + dest string + ver uint32 + wantErr error + }{ + {"pass - valid inputs", testAccount, testIssuanceID, 1, testDest, 0, nil}, + {"fail - invalid account", "bad", testIssuanceID, 1, testDest, 0, proof.ErrInvalidAddress}, + {"fail - invalid dest", testAccount, testIssuanceID, 1, "bad", 0, proof.ErrInvalidAddress}, + {"fail - invalid issuance ID", testAccount, "zz", 1, testDest, 0, proof.ErrInvalidIssuanceID}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash, err := proof.SendContextHash(tt.account, tt.issID, tt.seq, tt.dest, tt.ver) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Len(t, hash, mptcrypto.HashOutputSize*2) + }) + } +} + +func TestClawbackContextHash(t *testing.T) { + tests := []struct { + name string + account string + issID string + seq uint32 + holder string + wantErr error + }{ + {"pass - valid inputs", testAccount, testIssuanceID, 1, testHolder, nil}, + {"fail - invalid account", "bad", testIssuanceID, 1, testHolder, proof.ErrInvalidAddress}, + {"fail - invalid holder", testAccount, testIssuanceID, 1, "bad", proof.ErrInvalidAddress}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash, err := proof.ClawbackContextHash(tt.account, tt.issID, tt.seq, tt.holder) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Len(t, hash, mptcrypto.HashOutputSize*2) + }) + } +} diff --git a/confidential/proof/convert.go b/confidential/proof/convert.go new file mode 100644 index 000000000..be8f98c5f --- /dev/null +++ b/confidential/proof/convert.go @@ -0,0 +1,64 @@ +package proof + +import ( + "encoding/hex" + "fmt" + + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/Peersyst/xrpl-go/pkg/hexutil" +) + +// GenerateConvertProof generates a Schnorr proof of knowledge for a ConfidentialMPTConvert transaction. +// pubkeyHex: 66 hex chars, privkeyHex: 64 hex chars, ctxHashHex: 64 hex chars. +// Returns 128 hex chars (64-byte proof). +func GenerateConvertProof(pubkeyHex, privkeyHex, ctxHashHex string) (string, error) { + pubBytes, err := hexutil.DecodeFixedHex(pubkeyHex, mptcrypto.PubKeySize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidPubKey, err) + } + privBytes, err := hexutil.DecodeFixedHex(privkeyHex, mptcrypto.PrivKeySize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidPrivKey, err) + } + hashBytes, err := hexutil.DecodeFixedHex(ctxHashHex, mptcrypto.HashOutputSize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidContextHash, err) + } + + pub := mptcrypto.PublicKey(pubBytes) + priv := mptcrypto.PrivateKey(privBytes) + hash := mptcrypto.ContextHash(hashBytes) + + proof, err := mptcrypto.GenerateConvertProof(pub, priv, hash) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrProofGenerationFailed, err) + } + return hex.EncodeToString(proof[:]), nil +} + +// VerifyConvertProof verifies a Schnorr proof for a ConfidentialMPTConvert transaction. +// proofHex: 128 hex chars, pubkeyHex: 66 hex chars, ctxHashHex: 64 hex chars. +func VerifyConvertProof(proofHex, pubkeyHex, ctxHashHex string) error { + proofBytes, err := hexutil.DecodeFixedHex(proofHex, mptcrypto.SchnorrProofSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidProof, err) + } + pubBytes, err := hexutil.DecodeFixedHex(pubkeyHex, mptcrypto.PubKeySize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidPubKey, err) + } + hashBytes, err := hexutil.DecodeFixedHex(ctxHashHex, mptcrypto.HashOutputSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidContextHash, err) + } + + var proof [mptcrypto.SchnorrProofSize]byte + copy(proof[:], proofBytes) + pub := mptcrypto.PublicKey(pubBytes) + hash := mptcrypto.ContextHash(hashBytes) + + if err := mptcrypto.VerifyConvertProof(proof, pub, hash); err != nil { + return fmt.Errorf("%w: %w", ErrProofVerificationFailed, err) + } + return nil +} diff --git a/confidential/proof/convert_back.go b/confidential/proof/convert_back.go new file mode 100644 index 000000000..8c5a6c2ba --- /dev/null +++ b/confidential/proof/convert_back.go @@ -0,0 +1,76 @@ +package proof + +import ( + "encoding/hex" + "fmt" + + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/Peersyst/xrpl-go/pkg/hexutil" +) + +// GenerateConvertBackProof generates a compact sigma + range proof for a ConfidentialMPTConvertBack transaction. +// Returns hex-encoded proof string (1632 hex chars = 816 bytes). +func GenerateConvertBackProof(privkeyHex, pubkeyHex, ctxHashHex string, amount uint64, params Params) (string, error) { + privBytes, err := hexutil.DecodeFixedHex(privkeyHex, mptcrypto.PrivKeySize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidPrivKey, err) + } + pubBytes, err := hexutil.DecodeFixedHex(pubkeyHex, mptcrypto.PubKeySize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidPubKey, err) + } + hashBytes, err := hexutil.DecodeFixedHex(ctxHashHex, mptcrypto.HashOutputSize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidContextHash, err) + } + pp, err := decodeProofParams(params) + if err != nil { + return "", err + } + + priv := mptcrypto.PrivateKey(privBytes) + pub := mptcrypto.PublicKey(pubBytes) + hash := mptcrypto.ContextHash(hashBytes) + + proof, err := mptcrypto.GenerateConvertBackProof(priv, pub, hash, amount, pp) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrProofGenerationFailed, err) + } + return hex.EncodeToString(proof[:]), nil +} + +// VerifyConvertBackProof verifies a linkage + range proof for a ConfidentialMPTConvertBack transaction. +func VerifyConvertBackProof(proofHex, pubkeyHex, ciphertextHex, balanceCommitHex string, amount uint64, ctxHashHex string) error { + proofBytes, err := hexutil.DecodeFixedHex(proofHex, mptcrypto.ConvertBackProofSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidProof, err) + } + pubBytes, err := hexutil.DecodeFixedHex(pubkeyHex, mptcrypto.PubKeySize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidPubKey, err) + } + ctBytes, err := hexutil.DecodeFixedHex(ciphertextHex, mptcrypto.CiphertextSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidCiphertext, err) + } + commitBytes, err := hexutil.DecodeFixedHex(balanceCommitHex, mptcrypto.CommitmentSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidCommitment, err) + } + hashBytes, err := hexutil.DecodeFixedHex(ctxHashHex, mptcrypto.HashOutputSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidContextHash, err) + } + + var proof [mptcrypto.ConvertBackProofSize]byte + copy(proof[:], proofBytes) + pub := mptcrypto.PublicKey(pubBytes) + ct := mptcrypto.Ciphertext(ctBytes) + commit := mptcrypto.Commitment(commitBytes) + hash := mptcrypto.ContextHash(hashBytes) + + if err := mptcrypto.VerifyConvertBackProof(proof, pub, ct, commit, amount, hash); err != nil { + return fmt.Errorf("%w: %w", ErrProofVerificationFailed, err) + } + return nil +} diff --git a/confidential/proof/convert_back_test.go b/confidential/proof/convert_back_test.go new file mode 100644 index 000000000..0b2e41aec --- /dev/null +++ b/confidential/proof/convert_back_test.go @@ -0,0 +1,117 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package proof_test + +import ( + "testing" + + "github.com/Peersyst/xrpl-go/confidential/commitment" + "github.com/Peersyst/xrpl-go/confidential/elgamal" + "github.com/Peersyst/xrpl-go/confidential/proof" + "github.com/stretchr/testify/require" +) + +func TestGenerateAndVerifyConvertBackProof(t *testing.T) { + const balanceAmount uint64 = 1000 + const withdrawAmount uint64 = 100 + + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + + balanceCt, err := elgamal.Encrypt(balanceAmount, kp.PubKeyHex, bf) + require.NoError(t, err) + + balanceCommit, err := commitment.Create(balanceAmount, bf) + require.NoError(t, err) + + ctxHash, err := proof.ConvertBackContextHash(testAccount, testIssuanceID, 1, 0) + require.NoError(t, err) + + params := proof.Params{ + CommitmentHex: balanceCommit, + Amount: balanceAmount, + CiphertextHex: balanceCt, + BlindingFactorHex: bf, + } + + proofHex, err := proof.GenerateConvertBackProof(kp.PrivKeyHex, kp.PubKeyHex, ctxHash, withdrawAmount, params) + require.NoError(t, err) + require.NotEmpty(t, proofHex) + + err = proof.VerifyConvertBackProof(proofHex, kp.PubKeyHex, balanceCt, balanceCommit, withdrawAmount, ctxHash) + require.NoError(t, err) +} + +func TestConvertBackProofInvalidInputs(t *testing.T) { + tests := []struct { + name string + fn func() error + wantErr error + }{ + { + name: "fail - bad privkey", + fn: func() error { + _, err := proof.GenerateConvertBackProof("zz", "02"+zeroHex(32), zeroHex(32), 100, proof.Params{ + CommitmentHex: "02" + zeroHex(32), + CiphertextHex: zeroHex(66), + BlindingFactorHex: zeroHex(32), + }) + return err + }, + wantErr: proof.ErrInvalidPrivKey, + }, + { + name: "fail - bad pubkey", + fn: func() error { + _, err := proof.GenerateConvertBackProof(zeroHex(32), "zz", zeroHex(32), 100, proof.Params{ + CommitmentHex: "02" + zeroHex(32), + CiphertextHex: zeroHex(66), + BlindingFactorHex: zeroHex(32), + }) + return err + }, + wantErr: proof.ErrInvalidPubKey, + }, + { + name: "fail - bad ctx hash", + fn: func() error { + _, err := proof.GenerateConvertBackProof(zeroHex(32), "02"+zeroHex(32), "zz", 100, proof.Params{ + CommitmentHex: "02" + zeroHex(32), + CiphertextHex: zeroHex(66), + BlindingFactorHex: zeroHex(32), + }) + return err + }, + wantErr: proof.ErrInvalidContextHash, + }, + { + name: "fail - bad commitment in params", + fn: func() error { + _, err := proof.GenerateConvertBackProof(zeroHex(32), "02"+zeroHex(32), zeroHex(32), 100, proof.Params{ + CommitmentHex: "bad", + CiphertextHex: zeroHex(66), + BlindingFactorHex: zeroHex(32), + }) + return err + }, + wantErr: proof.ErrInvalidCommitment, + }, + { + name: "fail - verify bad proof", + fn: func() error { + return proof.VerifyConvertBackProof("0102", "02"+zeroHex(32), zeroHex(66), "02"+zeroHex(32), 100, zeroHex(32)) + }, + wantErr: proof.ErrInvalidProof, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.fn() + require.ErrorIs(t, err, tt.wantErr) + }) + } +} diff --git a/confidential/proof/convert_test.go b/confidential/proof/convert_test.go new file mode 100644 index 000000000..2a992828e --- /dev/null +++ b/confidential/proof/convert_test.go @@ -0,0 +1,92 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package proof_test + +import ( + "testing" + + "github.com/Peersyst/xrpl-go/confidential/elgamal" + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/Peersyst/xrpl-go/confidential/proof" + "github.com/stretchr/testify/require" +) + +func TestGenerateAndVerifyConvertProof(t *testing.T) { + kp, err := elgamal.GenerateKeypair() + require.NoError(t, err) + wrongKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + ctxHash, err := proof.ConvertContextHash(testAccount, testIssuanceID, 1) + require.NoError(t, err) + + proofHex, err := proof.GenerateConvertProof(kp.PubKeyHex, kp.PrivKeyHex, ctxHash) + require.NoError(t, err) + require.Len(t, proofHex, mptcrypto.SchnorrProofSize*2) + + tests := []struct { + name string + pubKey string + wantErr error + }{ + {name: "pass - correct key", pubKey: kp.PubKeyHex}, + {name: "fail - wrong key", pubKey: wrongKP.PubKeyHex, wantErr: proof.ErrProofVerificationFailed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := proof.VerifyConvertProof(proofHex, tt.pubKey, ctxHash) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestConvertProofInvalidInputs(t *testing.T) { + kp, _ := elgamal.GenerateKeypair() + ctxHash, _ := proof.ConvertContextHash(testAccount, testIssuanceID, 1) + + tests := []struct { + name string + fn func() error + wantErr error + }{ + { + name: "fail - generate bad pubkey", + fn: func() error { + _, err := proof.GenerateConvertProof("zz", kp.PrivKeyHex, ctxHash) + return err + }, + wantErr: proof.ErrInvalidPubKey, + }, + { + name: "fail - generate bad privkey", + fn: func() error { + _, err := proof.GenerateConvertProof(kp.PubKeyHex, "short", ctxHash) + return err + }, + wantErr: proof.ErrInvalidPrivKey, + }, + { + name: "fail - generate bad ctx hash", + fn: func() error { + _, err := proof.GenerateConvertProof(kp.PubKeyHex, kp.PrivKeyHex, "zz") + return err + }, + wantErr: proof.ErrInvalidContextHash, + }, + { + name: "fail - verify bad proof", + fn: func() error { + return proof.VerifyConvertProof("0102", kp.PubKeyHex, ctxHash) + }, + wantErr: proof.ErrInvalidProof, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.fn() + require.ErrorIs(t, err, tt.wantErr) + }) + } +} diff --git a/confidential/proof/errors.go b/confidential/proof/errors.go new file mode 100644 index 000000000..f2df01304 --- /dev/null +++ b/confidential/proof/errors.go @@ -0,0 +1,32 @@ +package proof + +import "errors" + +var ( + // ErrInvalidAddress is returned when a classic XRPL address cannot be decoded. + ErrInvalidAddress = errors.New("proof: invalid classic address") + // ErrInvalidIssuanceID is returned when an issuance ID is not 48 hex characters (24 bytes). + ErrInvalidIssuanceID = errors.New("proof: invalid issuance ID") + // ErrInvalidContextHash is returned when a context hash has an unexpected byte length. + ErrInvalidContextHash = errors.New("proof: invalid context hash") + // ErrInvalidProof is returned when a proof has an unexpected byte length. + ErrInvalidProof = errors.New("proof: invalid proof") + // ErrInvalidPubKey is returned when a public key has an unexpected byte length. + ErrInvalidPubKey = errors.New("proof: invalid public key") + // ErrInvalidPrivKey is returned when a private key has an unexpected byte length. + ErrInvalidPrivKey = errors.New("proof: invalid private key") + // ErrInvalidCiphertext is returned when a ciphertext has an unexpected byte length. + ErrInvalidCiphertext = errors.New("proof: invalid ciphertext") + // ErrInvalidCommitment is returned when a commitment has an unexpected byte length. + ErrInvalidCommitment = errors.New("proof: invalid commitment") + // ErrInvalidBlindingFactor is returned when a blinding factor has an unexpected byte length. + ErrInvalidBlindingFactor = errors.New("proof: invalid blinding factor length") + // ErrProofGenerationFailed is returned when the underlying C proof generation call fails. + ErrProofGenerationFailed = errors.New("proof: proof generation failed") + // ErrProofVerificationFailed is returned when the underlying C proof verification call fails. + ErrProofVerificationFailed = errors.New("proof: proof verification failed") + // ErrContextHashFailed is returned when the underlying C context hash computation fails. + ErrContextHashFailed = errors.New("proof: context hash computation failed") + // ErrNoParticipants is returned when the participants slice is empty. + ErrNoParticipants = errors.New("proof: at least one participant is required") +) diff --git a/confidential/proof/helpers.go b/confidential/proof/helpers.go new file mode 100644 index 000000000..52649a773 --- /dev/null +++ b/confidential/proof/helpers.go @@ -0,0 +1,85 @@ +package proof + +import ( + "fmt" + + addresscodec "github.com/Peersyst/xrpl-go/address-codec" + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/Peersyst/xrpl-go/pkg/hexutil" +) + +// decodeAddress decodes a classic XRPL address to a 20-byte account ID. +func decodeAddress(address string) ([mptcrypto.AccountIDSize]byte, error) { + var id [mptcrypto.AccountIDSize]byte + _, accountID, err := addresscodec.DecodeClassicAddressToAccountID(address) + if err != nil { + return id, fmt.Errorf("%w: %w", ErrInvalidAddress, err) + } + copy(id[:], accountID) + return id, nil +} + +// decodeIssuanceID decodes a 48-char hex issuance ID to a 24-byte array. +func decodeIssuanceID(issHex string) ([mptcrypto.IssuanceIDSize]byte, error) { + var id [mptcrypto.IssuanceIDSize]byte + b, err := hexutil.DecodeFixedHex(issHex, mptcrypto.IssuanceIDSize) + if err != nil { + return id, fmt.Errorf("%w: %w", ErrInvalidIssuanceID, err) + } + copy(id[:], b) + return id, nil +} + +// decodeParticipant converts a Participant to a mptcrypto.Participant. +func decodeParticipant(hp Participant) (mptcrypto.Participant, error) { + var p mptcrypto.Participant + pubBytes, err := hexutil.DecodeFixedHex(hp.PubKeyHex, mptcrypto.PubKeySize) + if err != nil { + return p, fmt.Errorf("%w: %w", ErrInvalidPubKey, err) + } + ctBytes, err := hexutil.DecodeFixedHex(hp.CiphertextHex, mptcrypto.CiphertextSize) + if err != nil { + return p, fmt.Errorf("%w: %w", ErrInvalidCiphertext, err) + } + p.PubKey = mptcrypto.PublicKey(pubBytes) + p.Ciphertext = mptcrypto.Ciphertext(ctBytes) + return p, nil +} + +// decodeParticipants converts a slice of Participant to mptcrypto.Participant. +func decodeParticipants(hps []Participant) ([]mptcrypto.Participant, error) { + if len(hps) == 0 { + return nil, ErrNoParticipants + } + parts := make([]mptcrypto.Participant, len(hps)) + for i, hp := range hps { + p, err := decodeParticipant(hp) + if err != nil { + return nil, err + } + parts[i] = p + } + return parts, nil +} + +// decodeProofParams converts a Params to a mptcrypto.PedersenProofParams. +func decodeProofParams(hp Params) (mptcrypto.PedersenProofParams, error) { + var p mptcrypto.PedersenProofParams + commitBytes, err := hexutil.DecodeFixedHex(hp.CommitmentHex, mptcrypto.CommitmentSize) + if err != nil { + return p, fmt.Errorf("%w: %w", ErrInvalidCommitment, err) + } + ctBytes, err := hexutil.DecodeFixedHex(hp.CiphertextHex, mptcrypto.CiphertextSize) + if err != nil { + return p, fmt.Errorf("%w: %w", ErrInvalidCiphertext, err) + } + bfBytes, err := hexutil.DecodeFixedHex(hp.BlindingFactorHex, mptcrypto.BlindingFactorSize) + if err != nil { + return p, fmt.Errorf("%w: %w", ErrInvalidBlindingFactor, err) + } + p.Commitment = mptcrypto.Commitment(commitBytes) + p.Amount = hp.Amount + p.Ciphertext = mptcrypto.Ciphertext(ctBytes) + p.BlindingFactor = mptcrypto.BlindingFactor(bfBytes) + return p, nil +} diff --git a/confidential/proof/helpers_test.go b/confidential/proof/helpers_test.go new file mode 100644 index 000000000..b6e45cb21 --- /dev/null +++ b/confidential/proof/helpers_test.go @@ -0,0 +1,17 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package proof_test + +import "strings" + +const ( + testAccount = "rDTXLQ7ZKZVKz33zJbHjgVShjsBnqMBhmN" + testDest = "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59" + testHolder = "rJKhsipKHooQbtS3v5Jro6N5Q7TMNPkoAs" + testIssuanceID = "000000000000000000000000000000000000000000000001" +) + +// zeroHex returns a hex string of n zero bytes (2*n hex chars). +func zeroHex(n int) string { + return strings.Repeat("00", n) +} diff --git a/confidential/proof/send.go b/confidential/proof/send.go new file mode 100644 index 000000000..ebfa2758b --- /dev/null +++ b/confidential/proof/send.go @@ -0,0 +1,93 @@ +package proof + +import ( + "encoding/hex" + "fmt" + + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/Peersyst/xrpl-go/pkg/hexutil" +) + +// GenerateSendProof generates the full proof (equality + linkage + range) for a ConfidentialMPTSend transaction. +// Returns a fixed-size proof string (946 bytes, 1892 hex chars). +// The C API limits participants to 255 (uint8); XRPL uses at most 4 (sender, dest, issuer, auditor). +func GenerateSendProof(privkeyHex string, pubkeyHex string, amount uint64, participants []Participant, txBFHex, ctxHashHex string, amountParams, balanceParams Params) (string, error) { + privBytes, err := hexutil.DecodeFixedHex(privkeyHex, mptcrypto.PrivKeySize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidPrivKey, err) + } + pubBytes, err := hexutil.DecodeFixedHex(pubkeyHex, mptcrypto.PubKeySize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidPubKey, err) + } + parts, err := decodeParticipants(participants) + if err != nil { + return "", err + } + bfBytes, err := hexutil.DecodeFixedHex(txBFHex, mptcrypto.BlindingFactorSize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidBlindingFactor, err) + } + hashBytes, err := hexutil.DecodeFixedHex(ctxHashHex, mptcrypto.HashOutputSize) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidContextHash, err) + } + ap, err := decodeProofParams(amountParams) + if err != nil { + return "", err + } + bp, err := decodeProofParams(balanceParams) + if err != nil { + return "", err + } + + priv := mptcrypto.PrivateKey(privBytes) + pub := mptcrypto.PublicKey(pubBytes) + bf := mptcrypto.BlindingFactor(bfBytes) + hash := mptcrypto.ContextHash(hashBytes) + + proof, err := mptcrypto.GenerateSendProof(priv, pub, amount, parts, bf, hash, ap.Commitment, bp) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrProofGenerationFailed, err) + } + return hex.EncodeToString(proof), nil +} + +// VerifySendProof verifies the full proof for a ConfidentialMPTSend transaction. +// The C API limits participants to 255 (uint8); XRPL uses at most 4 (sender, dest, issuer, auditor). +func VerifySendProof(proofHex string, participants []Participant, senderCtHex, amountCommitHex, balanceCommitHex, ctxHashHex string) error { + proofBytes, err := hexutil.DecodeFixedHex(proofHex, mptcrypto.SendProofSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidProof, err) + } + parts, err := decodeParticipants(participants) + if err != nil { + return err + } + senderCtBytes, err := hexutil.DecodeFixedHex(senderCtHex, mptcrypto.CiphertextSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidCiphertext, err) + } + amountCommitBytes, err := hexutil.DecodeFixedHex(amountCommitHex, mptcrypto.CommitmentSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidCommitment, err) + } + balanceCommitBytes, err := hexutil.DecodeFixedHex(balanceCommitHex, mptcrypto.CommitmentSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidCommitment, err) + } + hashBytes, err := hexutil.DecodeFixedHex(ctxHashHex, mptcrypto.HashOutputSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidContextHash, err) + } + + senderCt := mptcrypto.Ciphertext(senderCtBytes) + amountCommit := mptcrypto.Commitment(amountCommitBytes) + balanceCommit := mptcrypto.Commitment(balanceCommitBytes) + hash := mptcrypto.ContextHash(hashBytes) + + if err := mptcrypto.VerifySendProof(proofBytes, parts, senderCt, amountCommit, balanceCommit, hash); err != nil { + return fmt.Errorf("%w: %w", ErrProofVerificationFailed, err) + } + return nil +} diff --git a/confidential/proof/send_test.go b/confidential/proof/send_test.go new file mode 100644 index 000000000..7413be356 --- /dev/null +++ b/confidential/proof/send_test.go @@ -0,0 +1,219 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package proof_test + +import ( + "testing" + + "github.com/Peersyst/xrpl-go/confidential/commitment" + "github.com/Peersyst/xrpl-go/confidential/elgamal" + "github.com/Peersyst/xrpl-go/confidential/proof" + "github.com/stretchr/testify/require" +) + +// setupSendProofScenario creates a full scenario for testing ConfidentialMPTSend proof. +// Returns all the hex-encoded data needed to generate and verify a send proof. +func setupSendProofScenario(t *testing.T, sendAmount, senderBalance uint64, withAuditor bool) ( + senderKP elgamal.Keypair, + participants []proof.Participant, + txBF string, + ctxHash string, + amountParams proof.Params, + balanceParams proof.Params, + senderBalanceCt string, + amountCommitHex string, + balanceCommitHex string, +) { + t.Helper() + + // Generate keypairs for all parties. + senderKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + destKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + issuerKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + // Transaction blinding factor (used for send amount encryption). + txBF, err = elgamal.GenerateBlindingFactor() + require.NoError(t, err) + + // Balance blinding factor. + balanceBF, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + + // Encrypt send amount under each participant's key with the same bf. + senderAmountCt, err := elgamal.Encrypt(sendAmount, senderKP.PubKeyHex, txBF) + require.NoError(t, err) + destAmountCt, err := elgamal.Encrypt(sendAmount, destKP.PubKeyHex, txBF) + require.NoError(t, err) + issuerAmountCt, err := elgamal.Encrypt(sendAmount, issuerKP.PubKeyHex, txBF) + require.NoError(t, err) + + // Sender's balance ciphertext. + senderBalanceCt, err = elgamal.Encrypt(senderBalance, senderKP.PubKeyHex, balanceBF) + require.NoError(t, err) + + // Commitments. + amountCommitHex, err = commitment.Create(sendAmount, txBF) + require.NoError(t, err) + balanceCommitHex, err = commitment.Create(senderBalance, balanceBF) + require.NoError(t, err) + + // Context hash. + ctxHash, err = proof.SendContextHash(testAccount, testIssuanceID, 1, testDest, 0) + require.NoError(t, err) + + // Participants array. + participants = []proof.Participant{ + {PubKeyHex: senderKP.PubKeyHex, CiphertextHex: senderAmountCt}, + {PubKeyHex: destKP.PubKeyHex, CiphertextHex: destAmountCt}, + {PubKeyHex: issuerKP.PubKeyHex, CiphertextHex: issuerAmountCt}, + } + + if withAuditor { + auditorKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + auditorAmountCt, err := elgamal.Encrypt(sendAmount, auditorKP.PubKeyHex, txBF) + require.NoError(t, err) + participants = append(participants, proof.Participant{ + PubKeyHex: auditorKP.PubKeyHex, + CiphertextHex: auditorAmountCt, + }) + } + + // Proof params. + amountParams = proof.Params{ + CommitmentHex: amountCommitHex, + Amount: sendAmount, + CiphertextHex: senderAmountCt, + BlindingFactorHex: txBF, + } + balanceParams = proof.Params{ + CommitmentHex: balanceCommitHex, + Amount: senderBalance, + CiphertextHex: senderBalanceCt, + BlindingFactorHex: balanceBF, + } + + return +} + +func TestGenerateAndVerifySendProof(t *testing.T) { + tests := []struct { + name string + sendAmount uint64 + senderBalance uint64 + withAuditor bool + }{ + {"pass - 3 participants", 500, 1000, false}, + {"pass - 4 participants with auditor", 500, 1000, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + senderKP, participants, txBF, ctxHash, amountParams, balanceParams, senderBalanceCt, amountCommitHex, balanceCommitHex := setupSendProofScenario(t, tt.sendAmount, tt.senderBalance, tt.withAuditor) + + proofHex, err := proof.GenerateSendProof( + senderKP.PrivKeyHex, senderKP.PubKeyHex, tt.sendAmount, participants, txBF, ctxHash, + amountParams, balanceParams, + ) + require.NoError(t, err) + require.NotEmpty(t, proofHex) + + err = proof.VerifySendProof(proofHex, participants, senderBalanceCt, amountCommitHex, balanceCommitHex, ctxHash) + require.NoError(t, err) + }) + } +} + +func TestVerifySendProofRejectsWrongLength(t *testing.T) { + const amount = 500 + senderKP, participants, txBF, ctxHash, amountParams, balanceParams, senderBalanceCt, amountCommitHex, balanceCommitHex := setupSendProofScenario(t, amount, 1000, false) + + proofHex, err := proof.GenerateSendProof( + senderKP.PrivKeyHex, senderKP.PubKeyHex, amount, participants, txBF, ctxHash, + amountParams, balanceParams, + ) + require.NoError(t, err) + + err = proof.VerifySendProof(proofHex[:len(proofHex)-2], participants, senderBalanceCt, amountCommitHex, balanceCommitHex, ctxHash) + require.ErrorIs(t, err, proof.ErrInvalidProof) +} + +func TestSendProofInvalidInputs(t *testing.T) { + tests := []struct { + name string + fn func() error + wantErr error + }{ + { + name: "fail - bad privkey", + fn: func() error { + _, err := proof.GenerateSendProof("zz", "zz", 100, nil, zeroHex(32), zeroHex(32), + proof.Params{}, proof.Params{}) + return err + }, + wantErr: proof.ErrInvalidPrivKey, + }, + { + name: "fail - bad pubkey", + fn: func() error { + _, err := proof.GenerateSendProof(zeroHex(32), "zz", 100, nil, zeroHex(32), zeroHex(32), + proof.Params{}, proof.Params{}) + return err + }, + wantErr: proof.ErrInvalidPubKey, + }, + { + name: "fail - bad tx blinding factor", + fn: func() error { + pubKey := "02" + zeroHex(32) + _, err := proof.GenerateSendProof(zeroHex(32), pubKey, 100, + []proof.Participant{{PubKeyHex: pubKey, CiphertextHex: zeroHex(66)}}, + "bad", zeroHex(32), + proof.Params{}, proof.Params{}) + return err + }, + wantErr: proof.ErrInvalidBlindingFactor, + }, + { + name: "fail - bad ctx hash", + fn: func() error { + pubKey := "02" + zeroHex(32) + _, err := proof.GenerateSendProof(zeroHex(32), pubKey, 100, + []proof.Participant{{PubKeyHex: pubKey, CiphertextHex: zeroHex(66)}}, + zeroHex(32), "bad", + proof.Params{}, proof.Params{}) + return err + }, + wantErr: proof.ErrInvalidContextHash, + }, + { + name: "fail - bad participant pubkey", + fn: func() error { + _, err := proof.GenerateSendProof(zeroHex(32), zeroHex(32), 100, + []proof.Participant{{PubKeyHex: "zz", CiphertextHex: zeroHex(66)}}, + zeroHex(32), zeroHex(32), + proof.Params{CommitmentHex: "02" + zeroHex(32), CiphertextHex: zeroHex(66), BlindingFactorHex: zeroHex(32)}, + proof.Params{CommitmentHex: "02" + zeroHex(32), CiphertextHex: zeroHex(66), BlindingFactorHex: zeroHex(32)}) + return err + }, + wantErr: proof.ErrInvalidPubKey, + }, + { + name: "fail - verify bad proof hex", + fn: func() error { + return proof.VerifySendProof("zzzz", nil, zeroHex(66), "02"+zeroHex(32), "02"+zeroHex(32), zeroHex(32)) + }, + wantErr: proof.ErrInvalidProof, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.fn() + require.ErrorIs(t, err, tt.wantErr) + }) + } +} diff --git a/confidential/proof/types.go b/confidential/proof/types.go new file mode 100644 index 000000000..59d5ba267 --- /dev/null +++ b/confidential/proof/types.go @@ -0,0 +1,15 @@ +package proof + +// Participant represents a participant with hex-encoded fields. +type Participant struct { + PubKeyHex string // 66 hex chars (33 bytes) + CiphertextHex string // 132 hex chars (66 bytes) +} + +// Params holds hex-encoded Pedersen linkage proof parameters. +type Params struct { + CommitmentHex string // 66 hex chars (33 bytes) + Amount uint64 + CiphertextHex string // 132 hex chars (66 bytes) + BlindingFactorHex string // 64 hex chars (32 bytes) +} diff --git a/confidential/proof/verify.go b/confidential/proof/verify.go new file mode 100644 index 000000000..a55ae6097 --- /dev/null +++ b/confidential/proof/verify.go @@ -0,0 +1,73 @@ +package proof + +import ( + "fmt" + + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/Peersyst/xrpl-go/pkg/hexutil" +) + +// VerifyRevealedAmount verifies that a revealed amount and blinding factor are consistent +// with the participants' ciphertexts. auditor may be nil. +func VerifyRevealedAmount(amount uint64, bfHex string, holder, issuer Participant, auditor *Participant) error { + bfBytes, err := hexutil.DecodeFixedHex(bfHex, mptcrypto.BlindingFactorSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidBlindingFactor, err) + } + holderP, err := decodeParticipant(holder) + if err != nil { + return err + } + issuerP, err := decodeParticipant(issuer) + if err != nil { + return err + } + + bf := mptcrypto.BlindingFactor(bfBytes) + + var auditorP *mptcrypto.Participant + if auditor != nil { + a, err := decodeParticipant(*auditor) + if err != nil { + return err + } + auditorP = &a + } + + if err := mptcrypto.VerifyRevealedAmount(amount, bf, holderP, issuerP, auditorP); err != nil { + return fmt.Errorf("%w: %w", ErrProofVerificationFailed, err) + } + return nil +} + +// VerifySendRangeProof verifies that the transfer amount and remaining balance are within [0, 2^64-1]. +// balanceCommitHex must be the sender's original balance commitment, the C library derives the remainder internally. +func VerifySendRangeProof(proofHex, amountCommitHex, balanceCommitHex, ctxHashHex string) error { + proofBytes, err := hexutil.DecodeFixedHex(proofHex, mptcrypto.DoubleBulletproofSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidProof, err) + } + amountCommitBytes, err := hexutil.DecodeFixedHex(amountCommitHex, mptcrypto.CommitmentSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidCommitment, err) + } + balanceCommitBytes, err := hexutil.DecodeFixedHex(balanceCommitHex, mptcrypto.CommitmentSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidCommitment, err) + } + hashBytes, err := hexutil.DecodeFixedHex(ctxHashHex, mptcrypto.HashOutputSize) + if err != nil { + return fmt.Errorf("%w: %w", ErrInvalidContextHash, err) + } + + var proof [mptcrypto.DoubleBulletproofSize]byte + copy(proof[:], proofBytes) + amountCommit := mptcrypto.Commitment(amountCommitBytes) + balanceCommit := mptcrypto.Commitment(balanceCommitBytes) + hash := mptcrypto.ContextHash(hashBytes) + + if err := mptcrypto.VerifySendRangeProof(proof, amountCommit, balanceCommit, hash); err != nil { + return fmt.Errorf("%w: %w", ErrProofVerificationFailed, err) + } + return nil +} diff --git a/confidential/proof/verify_test.go b/confidential/proof/verify_test.go new file mode 100644 index 000000000..8707987af --- /dev/null +++ b/confidential/proof/verify_test.go @@ -0,0 +1,143 @@ +//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64) + +package proof_test + +import ( + "testing" + + "github.com/Peersyst/xrpl-go/confidential/elgamal" + "github.com/Peersyst/xrpl-go/confidential/mptcrypto" + "github.com/Peersyst/xrpl-go/confidential/proof" + "github.com/stretchr/testify/require" +) + +func TestVerifyRevealedAmount(t *testing.T) { + const amount uint64 = 42 + + holderKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + issuerKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + auditorKP, err := elgamal.GenerateKeypair() + require.NoError(t, err) + + bf, err := elgamal.GenerateBlindingFactor() + require.NoError(t, err) + + holderCt, err := elgamal.Encrypt(amount, holderKP.PubKeyHex, bf) + require.NoError(t, err) + issuerCt, err := elgamal.Encrypt(amount, issuerKP.PubKeyHex, bf) + require.NoError(t, err) + auditorCt, err := elgamal.Encrypt(amount, auditorKP.PubKeyHex, bf) + require.NoError(t, err) + + holder := proof.Participant{PubKeyHex: holderKP.PubKeyHex, CiphertextHex: holderCt} + issuer := proof.Participant{PubKeyHex: issuerKP.PubKeyHex, CiphertextHex: issuerCt} + auditor := &proof.Participant{PubKeyHex: auditorKP.PubKeyHex, CiphertextHex: auditorCt} + + tests := []struct { + name string + verifyAmount uint64 + auditor *proof.Participant + wantErr error + }{ + {name: "pass - without auditor", verifyAmount: amount}, + {name: "pass - with auditor", verifyAmount: amount, auditor: auditor}, + {name: "fail - wrong amount", verifyAmount: 999, wantErr: proof.ErrProofVerificationFailed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := proof.VerifyRevealedAmount(tt.verifyAmount, bf, holder, issuer, tt.auditor) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestVerifyRevealedAmountInvalidInputs(t *testing.T) { + tests := []struct { + name string + fn func() error + wantErr error + }{ + { + name: "fail - bad blinding factor", + fn: func() error { + return proof.VerifyRevealedAmount(42, "bad", + proof.Participant{PubKeyHex: zeroHex(33), CiphertextHex: zeroHex(66)}, + proof.Participant{PubKeyHex: zeroHex(33), CiphertextHex: zeroHex(66)}, + nil) + }, + wantErr: proof.ErrInvalidBlindingFactor, + }, + { + name: "fail - bad holder pubkey", + fn: func() error { + return proof.VerifyRevealedAmount(42, zeroHex(32), + proof.Participant{PubKeyHex: "zz", CiphertextHex: zeroHex(66)}, + proof.Participant{PubKeyHex: zeroHex(33), CiphertextHex: zeroHex(66)}, + nil) + }, + wantErr: proof.ErrInvalidPubKey, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.fn() + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestVerifySendRangeProofInvalidInputs(t *testing.T) { + tests := []struct { + name string + fn func() error + wantErr error + }{ + { + name: "fail - bad proof", + fn: func() error { + return proof.VerifySendRangeProof("zz", "02"+zeroHex(32), "02"+zeroHex(32), zeroHex(32)) + }, + wantErr: proof.ErrInvalidProof, + }, + { + name: "fail - bad amount commitment", + fn: func() error { + return proof.VerifySendRangeProof(zeroHex(754), "zz", "02"+zeroHex(32), zeroHex(32)) + }, + wantErr: proof.ErrInvalidCommitment, + }, + { + name: "fail - bad balance commitment", + fn: func() error { + return proof.VerifySendRangeProof(zeroHex(754), "02"+zeroHex(32), "zz", zeroHex(32)) + }, + wantErr: proof.ErrInvalidCommitment, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.fn() + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestVerifySendRangeProofRoundtrip(t *testing.T) { + senderKP, participants, txBF, ctxHash, amountParams, balanceParams, _, amountCommitHex, balanceCommitHex := setupSendProofScenario(t, 500, 1000, false) + + proofHex, err := proof.GenerateSendProof( + senderKP.PrivKeyHex, senderKP.PubKeyHex, 500, participants, txBF, ctxHash, + amountParams, balanceParams, + ) + require.NoError(t, err) + + rangeProofHex := proofHex[mptcrypto.CompactSendProofSize*2:] + + err = proof.VerifySendRangeProof(rangeProofHex, amountCommitHex, balanceCommitHex, ctxHash) + require.NoError(t, err) +} diff --git a/docs/changelog/v0.2.x/998_v0_2_1.md b/docs/changelog/v0.2.x/998_v0_2_1.md new file mode 100644 index 000000000..1a6fffef2 --- /dev/null +++ b/docs/changelog/v0.2.x/998_v0_2_1.md @@ -0,0 +1,53 @@ +--- +title: v0.2.1 +--- + +### Added + +#### docs + +- Added confidential MPT documentation covering the CGo requirement, package layout, transaction types, and high-level builders. + +#### confidential + +- Added CGo bindings and vendored native libraries for XRPLF `mpt-crypto`, with a `!cgo` fallback and maintainer tooling for dependency updates. +- Added hex-string APIs for ElGamal encryption, Pedersen commitments, context hashes, and zero-knowledge proof generation and verification. +- Added `test-confidential` and `update-mpt-crypto` Makefile targets and an automated dependency-update workflow. + +#### confidential/builder + +- Added online `Build*` and offline `Prepare*` helpers for confidential MPT send, convert, convert-back, clawback, and inbox-merge transactions. + +#### pkg/hexutil + +- Added `DecodeFixedHex()` to decode hexadecimal values and enforce their decoded byte length. + +#### binary-codec + +- Added XLS-96 confidential MPT fields and transaction definitions for `ConfidentialMPTSend`, `ConfidentialMPTConvert`, `ConfidentialMPTConvertBack`, `ConfidentialMPTMergeInbox`, and `ConfidentialMPTClawback`. + +#### xrpl + +- Added confidential-transfer flags and encryption-key fields to MPT issuance transaction and ledger-entry models. +- Added confidential balance fields to `MPToken`, five confidential MPT transaction models, and supporting amount, encryption-key, hex-blob, blinding-factor, and proof validation helpers. + +#### xrpl/hash + +- Added `MPToken()` and `MPTokenIssuance()` helpers for computing MPT ledger-entry keylet indexes. + +### Changed + +#### dependencies + +- Raised the minimum Go version to 1.25.12 and upgraded `golang.org/x/crypto` to v0.54.0, incorporating upstream standard-library and SSH security fixes. + +### Fixed + +#### confidential + +- Tightened participant and fixed-size proof validation before invoking the native verifier, and aligned proof helper naming with the current `mpt-crypto` contract. + +#### xrpl/transaction + +- Corrected confidential transaction key encoding and proof-size validation. +- Rejected `MPTokenIssuanceSet` transactions that combine encryption keys with `tmfMPTClearCanConfidentialAmount`. diff --git a/docs/docs/confidential/_category_.json b/docs/docs/confidential/_category_.json new file mode 100644 index 000000000..b3ec8fb7c --- /dev/null +++ b/docs/docs/confidential/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "confidential", + "position": 6, + "link": { + "type": "doc", + "id": "confidential/index" + } +} \ No newline at end of file diff --git a/docs/docs/confidential/builders.md b/docs/docs/confidential/builders.md new file mode 100644 index 000000000..282cf9a6b --- /dev/null +++ b/docs/docs/confidential/builders.md @@ -0,0 +1,187 @@ +--- +sidebar_position: 2 +--- + +# builders + +## Overview + +The `confidential/builder` package is the high-level entry point for XLS-96 transaction construction. + +Each operation comes in two forms: + +- `Build*`: queries live ledger state through a `LedgerQuerier`. +- `Prepare*`: builds the same transaction from explicit inputs, which is useful for offline signing or test fixtures. + +The `LedgerQuerier` interface is intentionally small, and both `rpc.Client` and `websocket.Client` satisfy it: + +```go +type LedgerQuerier interface { + GetAccountInfo(req *account.InfoRequest) (*account.InfoResponse, error) + GetLedgerEntry(req *ledger.EntryRequest) (*ledger.EntryResponse, error) +} +``` + +## Builder families + +### `BuildConvert` and `PrepareConvert` + +Use these for `ConfidentialMPTConvert`. + +- Queries or accepts the account sequence. +- Resolves issuer and optional auditor encryption keys from the `MPTokenIssuance`. +- Detects whether the holder is opting in for the first time. +- Encrypts the converted amount for the holder, issuer, and optional auditor. +- On first use, adds `HolderEncryptionKey` and generates the Schnorr proof required to register it. + +`Amount == 0` is allowed here because zero-amount convert is the opt-in path for registering a holder key. + +```go +tx, err := builder.BuildConvert(client, builder.BuildConvertParams{ + Account: holderAddress, + IssuanceID: issuanceID, + Amount: 100, + HolderPrivKey: holderPrivKeyHex, + HolderPubKey: holderPubKeyHex, +}) +``` + +### `BuildSend` and `PrepareSend` + +Use these for `ConfidentialMPTSend`. + +- Resolves issuer, auditor, sender, and destination encryption keys. +- Reads the sender `MPToken` state, including `ConfidentialBalanceSpending` and `ConfidentialBalanceVersion`. +- Decrypts the sender's current confidential balance with the supplied private key and inclusive `BalanceRange`. +- Encrypts the transfer amount for sender, destination, issuer, and optional auditor. +- Builds both Pedersen commitments and the composite send proof. + +This path requires the destination holder to already have a registered `HolderEncryptionKey`. + +```go +tx, err := builder.BuildSend(client, builder.BuildSendParams{ + Account: senderAddress, + Destination: receiverAddress, + IssuanceID: issuanceID, + Amount: 25, + SenderPrivKey: senderPrivKeyHex, + SenderPubKey: senderPubKeyHex, + BalanceRange: elgamal.AmountRange{ + Low: 0, + High: 1_000_000, + }, +}) +``` + +### `BuildConvertBack` and `PrepareConvertBack` + +Use these for `ConfidentialMPTConvertBack`. + +- Resolves issuer and optional auditor keys. +- Reads and decrypts the holder's current confidential spending balance within the supplied inclusive `BalanceRange`. +- Uses `ConfidentialBalanceVersion` from ledger state. +- Builds the encrypted withdrawal amount, balance commitment, and convert-back proof. + +```go +tx, err := builder.BuildConvertBack(client, builder.BuildConvertBackParams{ + Account: holderAddress, + IssuanceID: issuanceID, + Amount: 10, + HolderPrivKey: holderPrivKeyHex, + HolderPubKey: holderPubKeyHex, + BalanceRange: elgamal.AmountRange{ + Low: 0, + High: 1_000_000, + }, +}) +``` + +### Bounded balance decryption + +`BuildSend` and `BuildConvertBack` decrypt the current on-ledger spending balance before constructing a transaction. Their `BalanceRange` is the expected range of that **current balance**, not the amount being sent or converted back. + +The `Low` and `High` bounds are inclusive and must contain the plaintext balance. They must satisfy `Low <= High < math.MaxUint64`. Decryption searches the interval linearly, so use the narrowest practical range; unnecessarily large ranges can make transaction construction slow. Omitting `BalanceRange` produces `[0, 0]`, which only succeeds for a zero balance. + +`PrepareSend` and `PrepareConvertBack` do not decrypt ledger state because their `CurrentBalance` is supplied explicitly. + +### `BuildClawback` and `PrepareClawback` + +Use these for `ConfidentialMPTClawback`. + +- Resolves the issuer sequence and issuer encryption key. +- Reads the holder's `IssuerEncryptedBalance` from the ledger. +- Generates the equality proof that binds the clawback amount to the issuer-visible ciphertext. + +```go +tx, err := builder.BuildClawback(client, builder.BuildClawbackParams{ + Account: issuerAddress, + Holder: holderAddress, + IssuanceID: issuanceID, + Amount: 50, + IssuerPrivKey: issuerPrivKeyHex, +}) +``` + +### `BuildMergeInbox` and `PrepareMergeInbox` + +Use these for `ConfidentialMPTMergeInbox`. + +- Only needs the account, issuance ID, and sequence. +- Performs no cryptographic work. +- Lets a holder move confidential inbox balance into spending balance. + +```go +tx, err := builder.BuildMergeInbox(client, builder.BuildMergeInboxParams{ + Account: holderAddress, + IssuanceID: issuanceID, +}) +``` + +## `Build*` vs `Prepare*` + +Choose `Build*` when you have access to a live ledger connection and want the SDK to resolve: + +- account sequence numbers; +- issuer and auditor encryption keys; +- holder `MPToken` fields such as `HolderEncryptionKey`, `ConfidentialBalanceSpending`, `IssuerEncryptedBalance`, and `ConfidentialBalanceVersion`. + +Choose `Prepare*` when you already have those values and want deterministic, offline transaction assembly. + +## Typical flow + +1. Enable confidential transfers on the issuance with `MPTokenIssuanceCreate` or `MPTokenIssuanceSet`, including `IssuerEncryptionKey` and optionally `AuditorEncryptionKey`. +2. Generate a holder keypair with `confidential/elgamal.GenerateKeypair()`. +3. Opt the holder in with `BuildConvert` or `PrepareConvert`, optionally with `Amount: 0` for key registration only. +4. Use `BuildSend` for confidential transfers between opted-in holders. +5. Use `BuildMergeInbox` after receiving confidential transfers, if the holder wants to spend the received balance. +6. Use `BuildConvertBack` to move confidential balance back into public MPT balance. + +## Signing and submission + +Builders return concrete transaction structs from `xrpl/transaction`, so the rest of the flow is the same as other XRPL transactions: autofill any remaining fields if needed, sign with a wallet, then submit through RPC or WebSocket. + +```go +tx, err := builder.BuildSend(client, params) +if err != nil { + return err +} + +signed, err := wallet.Sign(tx) +if err != nil { + return err +} + +_, err = client.SubmitTx(signed, nil) +return err +``` + +## Common failure cases + +Most builder errors are explicit and map to missing ledger state or invalid inputs: + +- `ErrEncryptionKeyNotSet`: the issuance does not yet have the issuer encryption key configured. +- `ErrReceiverNotOptedIn`: the destination holder has no registered `HolderEncryptionKey`. +- `ErrMPTokenNotFound`: the account does not yet have the expected `MPToken` ledger entry. +- `ErrInsufficientBalance`: the requested confidential send or convert-back amount exceeds the decrypted balance. +- `elgamal.ErrInvalidAmountRange`: `BalanceRange` is inverted or its upper bound is `math.MaxUint64`. +- `ErrCryptoFailed`: a cryptographic primitive failed, the provided private key does not match ledger state, or the current balance falls outside `BalanceRange`. diff --git a/docs/docs/confidential/index.md b/docs/docs/confidential/index.md new file mode 100644 index 000000000..ce16879b4 --- /dev/null +++ b/docs/docs/confidential/index.md @@ -0,0 +1,88 @@ +--- +sidebar_position: 1 +sectionTopLabel: Packages +--- + +# confidential + +## Overview + +The `confidential` packages add support for XLS-96 confidential MPT workflows in `xrpl-go`. + +They cover three layers: + +- `confidential/mptcrypto`: low-level CGo bindings to the XRPLF `mpt-crypto` library. +- `confidential/elgamal`, `confidential/commitment`, `confidential/proof`: Go-friendly hex-string APIs for encryption, commitments, context hashes, and zero-knowledge proofs. +- `confidential/builder`: high-level transaction builders for constructing confidential MPT transactions from either ledger state or explicit inputs. + +## Build requirements + +Confidential MPT support depends on CGo-enabled builds. + +```bash +CGO_ENABLED=1 go test ./confidential/... +``` + +If CGo is disabled, `confidential/mptcrypto` returns `ErrCgoRequired`, which means the builder and proof helpers cannot perform the underlying cryptographic operations. + +## Package map + +### `confidential/elgamal` + +Use this package when you need raw confidential amount encryption helpers. + +- `GenerateKeypair()` creates a confidential holder, issuer, or auditor keypair. +- `GenerateBlindingFactor()` creates the shared randomness used across ciphertexts and commitments. +- `Encrypt()` encrypts a `uint64` amount to a compressed secp256k1 public key. +- `Decrypt(ciphertextHex, privateKeyHex, amountRange)` decrypts a confidential balance ciphertext with the matching private key by searching an inclusive `AmountRange`. + +Decryption requires bounds that contain the plaintext amount and satisfy `Low <= High < math.MaxUint64`. Search cost grows linearly with the interval size, so use the narrowest practical range: + +```go +amount, err := elgamal.Decrypt(ciphertextHex, privateKeyHex, elgamal.AmountRange{ + Low: 0, + High: 1_000_000, +}) +``` + +### `confidential/commitment` + +Use this package to create Pedersen commitments for confidential amounts. + +- `Create(amount, bf)` returns the compressed commitment used by confidential proofs and transaction fields such as `AmountCommitment` and `BalanceCommitment`. + +### `confidential/proof` + +Use this package if you want fine-grained control over proof generation or verification. + +- Context-hash helpers bind proofs to a specific XRPL transaction: `ConvertContextHash`, `ConvertBackContextHash`, `SendContextHash`, `ClawbackContextHash`. +- Top-level proof helpers mirror the confidential transaction families: `GenerateConvertProof`, `GenerateConvertBackProof`, `GenerateSendProof`, `GenerateClawbackProof`. +- Verification helpers let you validate proofs before submission or in tests. + +All APIs in this layer operate on hex strings and classic XRPL addresses, which makes them suitable for transaction assembly. + +## Confidential transaction types + +The `xrpl/transaction` package now includes five confidential MPT transaction types: + +- `ConfidentialMPTConvert`: moves public MPT into confidential balance and optionally registers the holder encryption key on first use. +- `ConfidentialMPTSend`: sends confidential MPT between opted-in holders using encrypted amounts plus a composite proof. +- `ConfidentialMPTConvertBack`: converts confidential balance back into public balance with a proof of sufficient confidential funds. +- `ConfidentialMPTClawback`: lets the issuer reclaim a holder's confidential balance with an equality proof. +- `ConfidentialMPTMergeInbox`: merges a holder's confidential inbox balance into their spending balance. + +Related XRPL types were extended as well: + +- `MPTokenIssuanceCreate` and `MPTokenIssuanceSet` support confidential-transfer flags and issuer/auditor encryption keys. +- `MPToken` and `MPTokenIssuance` ledger-entry types expose confidential balance and encryption-key fields. + +## When to use builders + +Use [`builders`](/docs/confidential/builders) when you want the SDK to: + +- fetch ledger state such as `Sequence`, registered encryption keys, and confidential balance fields; +- decrypt the holder's current confidential balance within a caller-supplied inclusive `BalanceRange` when required; +- generate ciphertexts, commitments, and ZK proofs with the correct context hash; +- return a ready-to-sign `xrpl/transaction` struct. + +Drop down to `elgamal`, `commitment`, and `proof` when you need custom transaction assembly, explicit control over proof inputs, or standalone verification in tests. diff --git a/docs/docs/installation.md b/docs/docs/installation.md index 63eb23a5c..24c7de758 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -62,5 +62,6 @@ Now that you have the `xrpl-go` package downloaded and imported in your project, To learn more about the `xrpl-go` packages, you can find the documentation for each package: +- [confidential](/docs/confidential) - [keypairs](/docs/keypairs) - [xrpl](/docs/xrpl/currency) diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 183f02f1c..1d094850c 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -23,3 +23,4 @@ The SDK can be split into the following packages: - `address-codec`: A package for encoding and decoding XRPL addresses. - [`keypairs`](/docs/keypairs): A package for generating and managing cryptographic keypairs. - [`xrpl`](/docs/xrpl/currency): The biggest package of the SDK. It contains clients, types, transactions, and utils to interact with the XRP Ledger. +- [`confidential`](/docs/confidential): XLS-96 confidential MPT support, including ElGamal encryption, zero-knowledge proofs, and high-level transaction builders. diff --git a/docs/docs/xrpl/_category_.json b/docs/docs/xrpl/_category_.json index 0e5f62616..92e9ad5f6 100644 --- a/docs/docs/xrpl/_category_.json +++ b/docs/docs/xrpl/_category_.json @@ -1,6 +1,6 @@ { "label": "xrpl", - "position": 6, + "position": 7, "link": { "description": "Here you can find the documentation for the xrpl package, including its subpackages." } diff --git a/docs/docs/xrpl/transaction.md b/docs/docs/xrpl/transaction.md index 9b5c8a9a7..ba2ae1fdb 100644 --- a/docs/docs/xrpl/transaction.md +++ b/docs/docs/xrpl/transaction.md @@ -25,6 +25,11 @@ These are the transaction types available in the XRPL: - [CredentialAccept](https://xrpl.org/docs/references/protocol/transactions/types/credentialaccept) - [CredentialCreate](https://xrpl.org/docs/references/protocol/transactions/types/credentialcreate) - [CredentialDelete](https://xrpl.org/docs/references/protocol/transactions/types/credentialdelete) +- `ConfidentialMPTClawback` +- `ConfidentialMPTConvert` +- `ConfidentialMPTConvertBack` +- `ConfidentialMPTMergeInbox` +- `ConfidentialMPTSend` - [DelegateSet](https://xrpl.org/docs/references/protocol/transactions/types/delegateset) - [DepositPreauth](https://xrpl.org/docs/references/protocol/transactions/types/depositpreauth) - [DIDDelete](https://xrpl.org/docs/references/protocol/transactions/types/diddelete) diff --git a/pkg/hexutil/hexutil.go b/pkg/hexutil/hexutil.go index e137bcc0d..6ee59204b 100644 --- a/pkg/hexutil/hexutil.go +++ b/pkg/hexutil/hexutil.go @@ -3,6 +3,7 @@ package hexutil import ( "encoding/hex" + "fmt" "strings" ) @@ -10,3 +11,15 @@ import ( func EncodeToUpperHex(b []byte) string { return strings.ToUpper(hex.EncodeToString(b)) } + +// DecodeFixedHex decodes a hex string and validates it decodes to exactly size bytes. +func DecodeFixedHex(hexStr string, size int) ([]byte, error) { + b, err := hex.DecodeString(hexStr) + if err != nil { + return nil, fmt.Errorf("invalid hex: %w", err) + } + if len(b) != size { + return nil, fmt.Errorf("expected %d bytes, got %d", size, len(b)) + } + return b, nil +} diff --git a/pkg/hexutil/hexutil_test.go b/pkg/hexutil/hexutil_test.go index cabc0d6bc..32bd124ef 100644 --- a/pkg/hexutil/hexutil_test.go +++ b/pkg/hexutil/hexutil_test.go @@ -46,3 +46,61 @@ func TestEncodeToUpperHex(t *testing.T) { }) } } + +func TestDecodeFixedHex(t *testing.T) { + tt := []struct { + name string + hex string + size int + wantErr bool + }{ + { + name: "pass - valid 4 bytes", + hex: "deadbeef", + size: 4, + wantErr: false, + }, + { + name: "pass - empty string with size 0", + hex: "", + size: 0, + wantErr: false, + }, + { + name: "fail - too short", + hex: "0102", + size: 32, + wantErr: true, + }, + { + name: "fail - too long", + hex: "010203", + size: 2, + wantErr: true, + }, + { + name: "fail - invalid hex chars", + hex: "zzzz", + size: 2, + wantErr: true, + }, + { + name: "fail - odd length hex", + hex: "012", + size: 1, + wantErr: true, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + got, err := DecodeFixedHex(tc.hex, tc.size) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Len(t, got, tc.size) + }) + } +} diff --git a/xrpl/hash/ledger.go b/xrpl/hash/ledger.go index 7a838cb55..bbb201c76 100644 --- a/xrpl/hash/ledger.go +++ b/xrpl/hash/ledger.go @@ -11,6 +11,14 @@ import ( "github.com/Peersyst/xrpl-go/pkg/hexutil" ) +const ( + ledgerSpaceVault = "0056" + ledgerSpaceLoanBroker = "006C" + ledgerSpaceLoan = "004C" + ledgerSpaceMPToken = "0074" + ledgerSpaceMPTokenIssuance = "007E" +) + // Vault computes the hash of a Vault ledger entry. // The hash is computed as SHA-512Half(ledgerSpaceHex('vault') + addressToHex(address) + sequence as 8-char hex). // @@ -23,11 +31,10 @@ func Vault(address string, sequence uint32) (string, error) { return "", fmt.Errorf("failed to decode address: %w", err) } - ledgerSpaceHex := "0056" addressHex := hex.EncodeToString(accountID) sequenceHex := fmt.Sprintf("%08x", sequence) - payload := ledgerSpaceHex + addressHex + sequenceHex + payload := ledgerSpaceVault + addressHex + sequenceHex payloadBytes, err := hex.DecodeString(payload) if err != nil { return "", fmt.Errorf("failed to decode hex payload: %w", err) @@ -48,11 +55,10 @@ func LoanBroker(address string, sequence uint32) (string, error) { return "", fmt.Errorf("failed to decode address: %w", err) } - ledgerSpaceHex := "006C" addressHex := hex.EncodeToString(accountID) sequenceHex := fmt.Sprintf("%08x", sequence) - payload := ledgerSpaceHex + addressHex + sequenceHex + payload := ledgerSpaceLoanBroker + addressHex + sequenceHex payloadBytes, err := hex.DecodeString(payload) if err != nil { return "", fmt.Errorf("failed to decode hex payload: %w", err) @@ -68,10 +74,9 @@ func LoanBroker(address string, sequence uint32) (string, error) { // loanSequence is the sequence number of the Loan. // Returns the computed hash of the Loan object. func Loan(loanBrokerID string, loanSequence uint32) (string, error) { - ledgerSpaceHex := "004C" sequenceHex := fmt.Sprintf("%08x", loanSequence) - payload := ledgerSpaceHex + loanBrokerID + sequenceHex + payload := ledgerSpaceLoan + loanBrokerID + sequenceHex payloadBytes, err := hex.DecodeString(payload) if err != nil { return "", fmt.Errorf("failed to decode hex payload: %w", err) @@ -85,6 +90,52 @@ func EncodeToHashString(bytes []byte) string { return hexutil.EncodeToUpperHex(crypto.Sha512Half(bytes)) } +// MPToken computes the hash of an MPToken ledger entry. +// The hash is computed as SHA-512Half(ledgerSpaceHex('mptoken') + MPTokenIssuance(issuanceIDHex) + addressToHex(holder)). +// +// issuanceIDHex is the 48-character hex-encoded MPTokenIssuanceID (Hash192, 24 bytes). +// holder is the classic address of the MPToken holder. +// Returns the computed hash of the MPToken object. +func MPToken(issuanceIDHex string, holder string) (string, error) { + issuanceKey, err := MPTokenIssuance(issuanceIDHex) + if err != nil { + return "", fmt.Errorf("failed to compute issuance key: %w", err) + } + + _, accountID, err := addresscodec.DecodeClassicAddressToAccountID(holder) + if err != nil { + return "", fmt.Errorf("failed to decode holder address: %w", err) + } + + holderHex := hex.EncodeToString(accountID) + payload := ledgerSpaceMPToken + issuanceKey + holderHex + payloadBytes, err := hex.DecodeString(payload) + if err != nil { + return "", fmt.Errorf("failed to decode hex payload: %w", err) + } + + return EncodeToHashString(payloadBytes), nil +} + +// MPTokenIssuance computes the hash of an MPTokenIssuance ledger entry. +// The hash is computed as SHA512Half(ledgerSpaceHex('mptIssuance') + issuanceIDHex). +// +// issuanceIDHex is the 48-character hex-encoded MPTokenIssuanceID (Hash192, 24 bytes). +// Returns the computed hash of the MPTokenIssuance object. +func MPTokenIssuance(issuanceIDHex string) (string, error) { + if len(issuanceIDHex) != 48 { + return "", fmt.Errorf("issuance ID must be 48 hex chars (24 bytes), got %d", len(issuanceIDHex)) + } + + payload := ledgerSpaceMPTokenIssuance + issuanceIDHex + payloadBytes, err := hex.DecodeString(payload) + if err != nil { + return "", fmt.Errorf("failed to decode hex payload: %w", err) + } + + return EncodeToHashString(payloadBytes), nil +} + // PaymentChannel computes the hash (channel ID) of a PaymentChannel ledger entry. // The hash is computed as SHA-512Half(0x0078 + sourceAccountID + destAccountID + sequence as 8-char hex). // @@ -107,7 +158,6 @@ func PaymentChannel(source, destination string, sequence uint32) (string, error) sourceHex := hex.EncodeToString(sourceID) destHex := hex.EncodeToString(destID) sequenceHex := fmt.Sprintf("%08x", sequence) - payload := ledgerSpaceHex + sourceHex + destHex + sequenceHex payloadBytes, err := hex.DecodeString(payload) if err != nil { diff --git a/xrpl/hash/ledger_test.go b/xrpl/hash/ledger_test.go index 3131231fe..6bc997777 100644 --- a/xrpl/hash/ledger_test.go +++ b/xrpl/hash/ledger_test.go @@ -95,3 +95,84 @@ func TestLoan(t *testing.T) { }) } } + +func TestMPToken(t *testing.T) { + tests := []struct { + name string + issuanceID string + holder string + want string + wantError bool + }{ + { + name: "pass - valid inputs", + issuanceID: "000000000000000000000000000000000000000000000001", + holder: "rDTXLQ7ZKZVKz33zJbHjgVShjsBnqMBhmN", + want: "421477BB4C4F7195FD4934C1161BCEE697A3472EAE4E176FEE33DB7A3DD46C3F", + }, + { + name: "pass - different issuance ID", + issuanceID: "000000000000000000000000000000000000000000000002", + holder: "rDTXLQ7ZKZVKz33zJbHjgVShjsBnqMBhmN", + want: "CF6A7BB8B75ACBE74B0D6D1DF6B446735000DC1D6B512AF0CABE28E551477619", + }, + { + name: "fail - wrong issuance ID length", + issuanceID: "0001", + holder: "rDTXLQ7ZKZVKz33zJbHjgVShjsBnqMBhmN", + wantError: true, + }, + { + name: "fail - invalid address", + issuanceID: "000000000000000000000000000000000000000000000001", + holder: "invalid", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MPToken(tt.issuanceID, tt.holder) + if tt.wantError { + require.Error(t, err) + require.Empty(t, got) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, got) + } + }) + } +} + +func TestMPTokenIssuance(t *testing.T) { + tests := []struct { + name string + issuanceID string + want string + wantError bool + }{ + { + name: "pass - valid issuance ID", + issuanceID: "000000000000000000000000000000000000000000000001", + want: "35AE3B1DD171EC091E8FE05D102B7AA5D9A40AA191EBEE00E4536EB677DF7879", + }, + { + name: "fail - wrong length", + issuanceID: "0001", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MPTokenIssuance(tt.issuanceID) + if tt.wantError { + require.Error(t, err) + require.Empty(t, got) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, got) + } + }) + } +} diff --git a/xrpl/ledger-entry-types/mptoken.go b/xrpl/ledger-entry-types/mptoken.go index 60e999f0b..6f3366f4a 100644 --- a/xrpl/ledger-entry-types/mptoken.go +++ b/xrpl/ledger-entry-types/mptoken.go @@ -36,6 +36,24 @@ type MPToken struct { PreviousTxnLgrSeq uint32 // A hint indicating which page of the owner directory links to this entry, in case the directory consists of multiple pages. OwnerNode uint64 + // The holder's encryption key for confidential transfers. + // Required for participating in confidential transfers. + HolderEncryptionKey string `json:",omitempty"` + // Encrypted balance value for issuer tracking purposes. + // This allows the issuer to track confidential balances. + IssuerEncryptedBalance string `json:",omitempty"` + // Encrypted balance value for auditor tracking purposes (if configured). + // This allows an auditor to track confidential balances. + AuditorEncryptedBalance string `json:",omitempty"` + // The confidential balance inbox for pending incoming transfers. + // Contains encrypted amounts that have not yet been merged. + ConfidentialBalanceInbox string `json:",omitempty"` + // The confidential balance available for spending. + // Contains the merged encrypted balance. + ConfidentialBalanceSpending string `json:",omitempty"` + // Version counter for the confidential balance. + // Incremented with each update to prevent replay attacks. + ConfidentialBalanceVersion uint32 `json:",omitempty"` } // EntryType returns the type of the ledger entry. diff --git a/xrpl/ledger-entry-types/mptoken_issuance.go b/xrpl/ledger-entry-types/mptoken_issuance.go index 65c02e7ca..0e21ffb1a 100644 --- a/xrpl/ledger-entry-types/mptoken_issuance.go +++ b/xrpl/ledger-entry-types/mptoken_issuance.go @@ -19,6 +19,8 @@ const ( LsfMPTCanTransfer uint32 = 0x00000020 // LsfMPTCanClawback if set, indicates that the issuer may use the Clawback transaction to claw back value from individual holders. LsfMPTCanClawback uint32 = 0x00000040 + // LsfMPTCanConfidentialAmount if set, indicates that confidential transfers are enabled for this token issuance. + LsfMPTCanConfidentialAmount uint32 = 0x00000080 ) // Ledger-state mutable flags for MPTokenIssuance (Lsmf prefix). @@ -39,6 +41,8 @@ const ( LsmfMPTCanMutateMetadata uint32 = 0x00010000 // LsmfMPTCanMutateTransferFee indicates the TransferFee can be mutated. LsmfMPTCanMutateTransferFee uint32 = 0x00020000 + // LsmfMPTCannotMutateCanConfidentialAmount if set, the lsfMPTCanConfidentialAmount flag can never be changed after the token is issued. + LsmfMPTCannotMutateCanConfidentialAmount uint32 = 0x00040000 ) // An MPTokenIssuance entry represents a single MPT issuance and holds data associated with the issuance itself. @@ -85,6 +89,15 @@ type MPTokenIssuance struct { DomainID string `json:",omitempty"` // MutableFlags indicates which properties of this MPT can be mutated after creation. MutableFlags uint32 `json:",omitempty"` + // The issuer's encryption key for confidential transfers. + // Required if confidential transfers are enabled. + IssuerEncryptionKey string `json:",omitempty"` + // The auditor's encryption key for confidential transfers. + // Optional; allows an auditor to decrypt confidential balances. + AuditorEncryptionKey string `json:",omitempty"` + // The encrypted outstanding amount for confidential transfers. + // Tracks total confidential MPT in circulation. + ConfidentialOutstandingAmount uint64 `json:",omitempty"` } // EntryType returns the type of the ledger entry. diff --git a/xrpl/ledger-entry-types/mptoken_issuance_test.go b/xrpl/ledger-entry-types/mptoken_issuance_test.go index 548be377b..81b07ed46 100644 --- a/xrpl/ledger-entry-types/mptoken_issuance_test.go +++ b/xrpl/ledger-entry-types/mptoken_issuance_test.go @@ -361,6 +361,78 @@ func TestMPTokenIssuanceSerialization(t *testing.T) { "PreviousTxnLgrSeq": 234644, "Sequence": 1, "MutableFlags": 65538 +}`, + }, + { + name: "pass - valid MPTokenIssuance with LsfMPTCanConfidentialAmount", + mpTokenIssuance: &MPTokenIssuance{ + Index: types.Hash256("A738A1E6E8505E1FC77BBB9FEF84FF9A9C609F2739E0F9573CDD6367100A0AA9"), + LedgerEntryType: MPTokenIssuanceEntry, + Flags: LsfMPTCanConfidentialAmount, + Issuer: types.Address("rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD"), + AssetScale: 2, + MaximumAmount: 1000, + OutstandingAmount: 100, + TransferFee: 100, + MPTokenMetadata: "7B227469636B6572", + OwnerNode: 1, + PreviousTxnID: types.Hash256("8089451B193AAD110ACED3D62BE79BB523658545E6EE8B7BB0BE573FED9BCBFB"), + PreviousTxnLgrSeq: 234644, + Sequence: 1, + }, + expected: `{ + "index": "A738A1E6E8505E1FC77BBB9FEF84FF9A9C609F2739E0F9573CDD6367100A0AA9", + "LedgerEntryType": "MPTokenIssuance", + "Flags": 128, + "Issuer": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "AssetScale": 2, + "MaximumAmount": 1000, + "OutstandingAmount": 100, + "TransferFee": 100, + "MPTokenMetadata": "7B227469636B6572", + "OwnerNode": 1, + "PreviousTxnID": "8089451B193AAD110ACED3D62BE79BB523658545E6EE8B7BB0BE573FED9BCBFB", + "PreviousTxnLgrSeq": 234644, + "Sequence": 1 +}`, + }, + { + name: "pass - valid MPTokenIssuance with confidential transfer fields", + mpTokenIssuance: &MPTokenIssuance{ + Index: types.Hash256("A738A1E6E8505E1FC77BBB9FEF84FF9A9C609F2739E0F9573CDD6367100A0AA9"), + LedgerEntryType: MPTokenIssuanceEntry, + Flags: LsfMPTCanConfidentialAmount | LsfMPTCanTransfer, + Issuer: types.Address("rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD"), + AssetScale: 2, + MaximumAmount: 1000, + OutstandingAmount: 100, + TransferFee: 100, + MPTokenMetadata: "7B227469636B6572", + OwnerNode: 1, + PreviousTxnID: types.Hash256("8089451B193AAD110ACED3D62BE79BB523658545E6EE8B7BB0BE573FED9BCBFB"), + PreviousTxnLgrSeq: 234644, + Sequence: 1, + IssuerEncryptionKey: "AABBCCDD", + AuditorEncryptionKey: "EEFF0011", + ConfidentialOutstandingAmount: 500, + }, + expected: `{ + "index": "A738A1E6E8505E1FC77BBB9FEF84FF9A9C609F2739E0F9573CDD6367100A0AA9", + "LedgerEntryType": "MPTokenIssuance", + "Flags": 160, + "Issuer": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "AssetScale": 2, + "MaximumAmount": 1000, + "OutstandingAmount": 100, + "TransferFee": 100, + "MPTokenMetadata": "7B227469636B6572", + "OwnerNode": 1, + "PreviousTxnID": "8089451B193AAD110ACED3D62BE79BB523658545E6EE8B7BB0BE573FED9BCBFB", + "PreviousTxnLgrSeq": 234644, + "Sequence": 1, + "IssuerEncryptionKey": "AABBCCDD", + "AuditorEncryptionKey": "EEFF0011", + "ConfidentialOutstandingAmount": 500 }`, }, } diff --git a/xrpl/ledger-entry-types/mptoken_test.go b/xrpl/ledger-entry-types/mptoken_test.go index 1969d850e..288246b5a 100644 --- a/xrpl/ledger-entry-types/mptoken_test.go +++ b/xrpl/ledger-entry-types/mptoken_test.go @@ -136,6 +136,43 @@ func TestMPTokenSerialization(t *testing.T) { "PreviousTxnID": "8089451B193AAD110ACED3D62BE79BB523658545E6EE8B7BB0BE573FED9BCBFB", "PreviousTxnLgrSeq": 234644, "OwnerNode": 1 +}`, + }, + { + name: "pass - valid MPToken with confidential transfer fields", + mpToken: &MPToken{ + Index: types.Hash256("A738A1E6E8505E1FC77BBB9FEF84FF9A9C609F2739E0F9573CDD6367100A0AA9"), + LedgerEntryType: MPTokenEntry, + Flags: LsfMPTAuthorized, + Account: types.Address("rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD"), + MPTokenIssuanceID: types.Hash192("rU6K7V3Po4snVhBBaU29sesqs2qTQJWDw1"), + MPTAmount: 1000000, + PreviousTxnID: types.Hash256("8089451B193AAD110ACED3D62BE79BB523658545E6EE8B7BB0BE573FED9BCBFB"), + PreviousTxnLgrSeq: 234644, + OwnerNode: 1, + HolderEncryptionKey: "AABBCCDD", + IssuerEncryptedBalance: "1122334455", + AuditorEncryptedBalance: "6677889900", + ConfidentialBalanceInbox: "AABB", + ConfidentialBalanceSpending: "CCDD", + ConfidentialBalanceVersion: 3, + }, + expected: `{ + "index": "A738A1E6E8505E1FC77BBB9FEF84FF9A9C609F2739E0F9573CDD6367100A0AA9", + "LedgerEntryType": "MPToken", + "Flags": 2, + "Account": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "MPTokenIssuanceID": "rU6K7V3Po4snVhBBaU29sesqs2qTQJWDw1", + "MPTAmount": 1000000, + "PreviousTxnID": "8089451B193AAD110ACED3D62BE79BB523658545E6EE8B7BB0BE573FED9BCBFB", + "PreviousTxnLgrSeq": 234644, + "OwnerNode": 1, + "HolderEncryptionKey": "AABBCCDD", + "IssuerEncryptedBalance": "1122334455", + "AuditorEncryptedBalance": "6677889900", + "ConfidentialBalanceInbox": "AABB", + "ConfidentialBalanceSpending": "CCDD", + "ConfidentialBalanceVersion": 3 }`, }, } diff --git a/xrpl/transaction/confidential_mpt_clawback.go b/xrpl/transaction/confidential_mpt_clawback.go new file mode 100644 index 000000000..4b7a25090 --- /dev/null +++ b/xrpl/transaction/confidential_mpt_clawback.go @@ -0,0 +1,89 @@ +package transaction + +import ( + addresscodec "github.com/Peersyst/xrpl-go/address-codec" + "github.com/Peersyst/xrpl-go/xrpl/transaction/types" +) + +// ConfidentialMPTClawback allows the issuer to reclaim a holder's entire confidential +// MPT balance. Unlike regular clawback, the issuer must provide a ZK equality proof +// demonstrating knowledge of the encrypted balance since balances are not visible. +// Can only be submitted by the issuer, and only if TfMPTCanClawback was enabled at issuance. +// +// ```json +// +// { +// "TransactionType": "ConfidentialMPTClawback", +// "Fee": "10", +// "Holder": "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", +// "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", +// "MPTAmount": "1000", +// "ZKProof": "AABB..." +// } +// +// ``` +type ConfidentialMPTClawback struct { + BaseTx + // MPTokenIssuanceID identifies the MPTokenIssuance from which to clawback. + MPTokenIssuanceID string + // Holder is the holder account from which to clawback confidential balance. + Holder types.Address + // MPTAmount is the amount of MPT to clawback from the holder's confidential balance. + // Must be greater than 0. + MPTAmount types.MPTPlainAmount + // ZKProof is a zero-knowledge proof proving the holder has sufficient confidential + // balance for the clawback and that the operation is valid. + ZKProof string +} + +// TxType returns the transaction type (ConfidentialMPTClawback). +func (*ConfidentialMPTClawback) TxType() TxType { + return ConfidentialMPTClawbackTx +} + +// Flatten returns the flattened map of the ConfidentialMPTClawback transaction. +func (tx *ConfidentialMPTClawback) Flatten() FlatTransaction { + flattened := tx.BaseTx.Flatten() + + flattened["TransactionType"] = tx.TxType().String() + + flattened["Holder"] = tx.Holder.String() + + flattened["MPTokenIssuanceID"] = tx.MPTokenIssuanceID + + flattened["MPTAmount"] = tx.MPTAmount.Flatten() + + flattened["ZKProof"] = tx.ZKProof + + return flattened +} + +// Validate validates the ConfidentialMPTClawback transaction. +func (tx *ConfidentialMPTClawback) Validate() (bool, error) { + ok, err := tx.BaseTx.Validate() + if err != nil || !ok { + return false, err + } + + if tx.MPTokenIssuanceID == "" { + return false, ErrConfidentialMPTInvalidIssuanceID + } + + if !addresscodec.IsValidAddress(tx.Holder.String()) { + return false, ErrConfidentialClawbackInvalidHolder + } + + if tx.Holder.String() == tx.Account.String() { + return false, ErrConfidentialClawbackSelfClawback + } + + if tx.MPTAmount == 0 { + return false, ErrConfidentialClawbackInvalidAmount + } + + if !IsValidFixedHexBlob(tx.ZKProof, ClawbackProofLen) { + return false, ErrConfidentialClawbackBadProof + } + + return true, nil +} diff --git a/xrpl/transaction/confidential_mpt_clawback_test.go b/xrpl/transaction/confidential_mpt_clawback_test.go new file mode 100644 index 000000000..ce14951dd --- /dev/null +++ b/xrpl/transaction/confidential_mpt_clawback_test.go @@ -0,0 +1,194 @@ +package transaction + +import ( + "strings" + "testing" + + "github.com/Peersyst/xrpl-go/xrpl/transaction/types" + "github.com/stretchr/testify/require" +) + +var testClawbackProof = strings.Repeat("AB", ClawbackProofLen/2) + +func TestConfidentialMPTClawback_TxType(t *testing.T) { + tx := &ConfidentialMPTClawback{} + require.Equal(t, ConfidentialMPTClawbackTx, tx.TxType()) +} + +func TestConfidentialMPTClawback_Flatten(t *testing.T) { + tests := []struct { + name string + tx *ConfidentialMPTClawback + expected FlatTransaction + }{ + { + name: "pass - all fields", + tx: &ConfidentialMPTClawback{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + Fee: types.XRPCurrencyAmount(12), + }, + Holder: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + ZKProof: testClawbackProof, + }, + expected: FlatTransaction{ + "Account": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "Fee": "12", + "TransactionType": "ConfidentialMPTClawback", + "Holder": "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + "MPTAmount": "1000", + "ZKProof": testClawbackProof, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flattened := tt.tx.Flatten() + require.Equal(t, tt.expected, flattened) + }) + } +} + +func TestConfidentialMPTClawback_Validate(t *testing.T) { + tests := []struct { + name string + tx *ConfidentialMPTClawback + wantErr error + }{ + { + name: "pass - valid transaction", + tx: &ConfidentialMPTClawback{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTClawbackTx, + Fee: types.XRPCurrencyAmount(12), + }, + Holder: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + ZKProof: testClawbackProof, + }, + wantErr: nil, + }, + { + name: "fail - empty MPTokenIssuanceID", + tx: &ConfidentialMPTClawback{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTClawbackTx, + Fee: types.XRPCurrencyAmount(12), + }, + Holder: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "", + MPTAmount: types.MPTPlainAmount(1000), + ZKProof: testClawbackProof, + }, + wantErr: ErrConfidentialMPTInvalidIssuanceID, + }, + { + name: "fail - invalid Holder address", + tx: &ConfidentialMPTClawback{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTClawbackTx, + Fee: types.XRPCurrencyAmount(12), + }, + Holder: "invalidAddress", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + ZKProof: testClawbackProof, + }, + wantErr: ErrConfidentialClawbackInvalidHolder, + }, + { + name: "fail - Holder same as Account", + tx: &ConfidentialMPTClawback{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTClawbackTx, + Fee: types.XRPCurrencyAmount(12), + }, + Holder: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + ZKProof: testClawbackProof, + }, + wantErr: ErrConfidentialClawbackSelfClawback, + }, + { + name: "fail - zero MPTAmount", + tx: &ConfidentialMPTClawback{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTClawbackTx, + Fee: types.XRPCurrencyAmount(12), + }, + Holder: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(0), + ZKProof: testClawbackProof, + }, + wantErr: ErrConfidentialClawbackInvalidAmount, + }, + { + name: "fail - short ZKProof", + tx: &ConfidentialMPTClawback{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTClawbackTx, + Fee: types.XRPCurrencyAmount(12), + }, + Holder: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + ZKProof: "A1B2C3D4", + }, + wantErr: ErrConfidentialClawbackBadProof, + }, + { + name: "fail - empty ZKProof", + tx: &ConfidentialMPTClawback{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTClawbackTx, + Fee: types.XRPCurrencyAmount(12), + }, + Holder: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + ZKProof: "", + }, + wantErr: ErrConfidentialClawbackBadProof, + }, + { + name: "fail - invalid hex ZKProof", + tx: &ConfidentialMPTClawback{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTClawbackTx, + Fee: types.XRPCurrencyAmount(12), + }, + Holder: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + ZKProof: "ZZZZ", + }, + wantErr: ErrConfidentialClawbackBadProof, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.tx.Validate() + if tt.wantErr != nil { + require.EqualError(t, err, tt.wantErr.Error()) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/xrpl/transaction/confidential_mpt_constants.go b/xrpl/transaction/confidential_mpt_constants.go new file mode 100644 index 000000000..8fbb0629f --- /dev/null +++ b/xrpl/transaction/confidential_mpt_constants.go @@ -0,0 +1,72 @@ +package transaction + +import "github.com/Peersyst/xrpl-go/pkg/typecheck" + +// CompressedPointLen is the hex-encoded length of a 33-byte compressed EC point. +// Used for compressed public keys (IssuerEncryptionKey, AuditorEncryptionKey) and +// Pedersen commitments (BalanceCommitment, AmountCommitment). +const CompressedPointLen = 66 + +// CiphertextLen is the hex-encoded length of a 66-byte ElGamal ciphertext (two compressed EC points). +const CiphertextLen = 2 * CompressedPointLen + +// PrivKeyLen is the hex-encoded length of a 32-byte private key scalar. +const PrivKeyLen = 64 + +// BlindingFactorLen is the hex-encoded length of a 32-byte blinding factor scalar. +const BlindingFactorLen = 64 + +// SchnorrProofLen is the hex-encoded length of a 64-byte compact Schnorr proof of knowledge. +const SchnorrProofLen = 128 + +// SendProofLen is the hex-encoded length of a 946-byte confidential send proof bundle. +const SendProofLen = 1892 + +// ConvertBackProofLen is the hex-encoded length of an 816-byte confidential convert back proof bundle. +const ConvertBackProofLen = 1632 + +// ClawbackProofLen is the hex-encoded length of a 64-byte confidential clawback proof. +const ClawbackProofLen = 128 + +// IsValidPrivKey checks if the given hex string is a valid 32-byte private key scalar (64 hex chars). +func IsValidPrivKey(key string) bool { + return len(key) == PrivKeyLen && typecheck.IsHex(key) +} + +// IsValidCompressedEncryptionKey checks if the given hex string is a valid +// 33-byte compressed EC public key (66 hex chars). +// Used for IssuerEncryptionKey and AuditorEncryptionKey per XLS-96. +func IsValidCompressedEncryptionKey(key string) bool { + return len(key) == CompressedPointLen && typecheck.IsHex(key) +} + +// IsValidBlindingFactor checks if the given hex string is a valid 32-byte blinding factor (64 hex chars). +func IsValidBlindingFactor(bf string) bool { + return len(bf) == BlindingFactorLen && typecheck.IsHex(bf) +} + +// IsValidSchnorrProof checks if the given hex string is a valid 64-byte Schnorr proof (128 hex chars). +func IsValidSchnorrProof(proof string) bool { + return len(proof) == SchnorrProofLen && typecheck.IsHex(proof) +} + +// IsValidCiphertext checks if the given hex string is a valid 66-byte ElGamal ciphertext (132 hex chars). +func IsValidCiphertext(s string) bool { + return len(s) == CiphertextLen && typecheck.IsHex(s) +} + +// IsValidCommitment checks if the given hex string is a valid 33-byte Pedersen commitment (66 hex chars). +func IsValidCommitment(s string) bool { + return len(s) == CompressedPointLen && typecheck.IsHex(s) +} + +// IsValidHexBlob checks if the given string is a non-empty valid hex-encoded blob. +// Used for variable-length fields like ZKProof bundles where the spec does not define a fixed size. +func IsValidHexBlob(s string) bool { + return len(s) > 0 && typecheck.IsHex(s) +} + +// IsValidFixedHexBlob checks if the given string is valid hex with an exact encoded length. +func IsValidFixedHexBlob(s string, hexLen int) bool { + return len(s) == hexLen && typecheck.IsHex(s) +} diff --git a/xrpl/transaction/confidential_mpt_convert.go b/xrpl/transaction/confidential_mpt_convert.go new file mode 100644 index 000000000..96bbd8c16 --- /dev/null +++ b/xrpl/transaction/confidential_mpt_convert.go @@ -0,0 +1,127 @@ +package transaction + +import "github.com/Peersyst/xrpl-go/xrpl/transaction/types" + +// ConfidentialMPTConvert converts public MPT balance into confidential (encrypted) balance. +// The amount being converted is specified in cleartext, but the resulting balance is encrypted +// using EC-ElGamal encryption. On first use, the holder registers their ElGamal encryption key +// by providing HolderEncryptionKey and ZKProof (Schnorr proof of knowledge). +// ZKProof must be present if and only if HolderEncryptionKey is present, +// and must be absent when HolderEncryptionKey is absent. +// +// ```json +// +// { +// "TransactionType": "ConfidentialMPTConvert", +// "Fee": "10", +// "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", +// "MPTAmount": "1000", +// "HolderEncryptedAmount": "AABB...", +// "IssuerEncryptedAmount": "CCDD...", +// "BlindingFactor": "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" +// } +// +// ``` +type ConfidentialMPTConvert struct { + BaseTx + // MPTokenIssuanceID identifies the MPTokenIssuance for which to convert balance. + MPTokenIssuanceID string + // MPTAmount is the amount of MPT to convert from public to confidential balance. + MPTAmount types.MPTPlainAmount + // HolderEncryptionKey is the holder's EC-ElGamal public key for encryption. (Optional) + // Required if the holder doesn't already have a key registered. + // 33 bytes compressed EC point, hex-encoded (66 hex chars). + HolderEncryptionKey *string `json:",omitempty"` + // HolderEncryptedAmount is the encrypted amount for the holder's confidential balance. + // 66 bytes (two 33-byte compressed EC points), hex-encoded. + HolderEncryptedAmount string + // IssuerEncryptedAmount is the encrypted amount for the issuer's tracking purposes. + // 66 bytes (two 33-byte compressed EC points), hex-encoded. + IssuerEncryptedAmount string + // AuditorEncryptedAmount is the encrypted amount for the auditor (if configured). (Optional) + // 66 bytes (two 33-byte compressed EC points), hex-encoded. + AuditorEncryptedAmount *string `json:",omitempty"` + // BlindingFactor is the 32-byte scalar value used to encrypt the amount. + // Used by validators to verify the ciphertexts match the plaintext MPTAmount. + BlindingFactor string + // ZKProof is a Schnorr proof of knowledge. Required if and only if + // HolderEncryptionKey is present, must be absent otherwise. (Optional) + ZKProof *string `json:",omitempty"` +} + +// TxType returns the transaction type (ConfidentialMPTConvert). +func (*ConfidentialMPTConvert) TxType() TxType { + return ConfidentialMPTConvertTx +} + +// Flatten returns the flattened map of the ConfidentialMPTConvert transaction. +func (tx *ConfidentialMPTConvert) Flatten() FlatTransaction { + flattened := tx.BaseTx.Flatten() + + flattened["TransactionType"] = tx.TxType().String() + + flattened["MPTokenIssuanceID"] = tx.MPTokenIssuanceID + + flattened["MPTAmount"] = tx.MPTAmount.Flatten() + + if tx.HolderEncryptionKey != nil { + flattened["HolderEncryptionKey"] = *tx.HolderEncryptionKey + } + + flattened["HolderEncryptedAmount"] = tx.HolderEncryptedAmount + + flattened["IssuerEncryptedAmount"] = tx.IssuerEncryptedAmount + + if tx.AuditorEncryptedAmount != nil { + flattened["AuditorEncryptedAmount"] = *tx.AuditorEncryptedAmount + } + + flattened["BlindingFactor"] = tx.BlindingFactor + + if tx.ZKProof != nil { + flattened["ZKProof"] = *tx.ZKProof + } + + return flattened +} + +// Validate validates the ConfidentialMPTConvert transaction. +func (tx *ConfidentialMPTConvert) Validate() (bool, error) { + ok, err := tx.BaseTx.Validate() + if err != nil || !ok { + return false, err + } + + if tx.MPTokenIssuanceID == "" { + return false, ErrConfidentialMPTInvalidIssuanceID + } + + // HolderEncryptionKey and ZKProof must both be present or both absent. + hasKey := tx.HolderEncryptionKey != nil + hasProof := tx.ZKProof != nil + if hasKey != hasProof { + return false, ErrConfidentialConvertKeyProofMismatch + } + + if hasKey && !IsValidCompressedEncryptionKey(*tx.HolderEncryptionKey) { + return false, ErrConfidentialConvertInvalidKeyLength + } + + if hasProof && !IsValidSchnorrProof(*tx.ZKProof) { + return false, ErrConfidentialConvertInvalidProofLength + } + + if !IsValidBlindingFactor(tx.BlindingFactor) { + return false, ErrConfidentialConvertInvalidBlindingFactor + } + + if !IsValidCiphertext(tx.HolderEncryptedAmount) || !IsValidCiphertext(tx.IssuerEncryptedAmount) { + return false, ErrConfidentialConvertInvalidCiphertext + } + + if tx.AuditorEncryptedAmount != nil && !IsValidCiphertext(*tx.AuditorEncryptedAmount) { + return false, ErrConfidentialConvertInvalidCiphertext + } + + return true, nil +} diff --git a/xrpl/transaction/confidential_mpt_convert_back.go b/xrpl/transaction/confidential_mpt_convert_back.go new file mode 100644 index 000000000..79d7bdf59 --- /dev/null +++ b/xrpl/transaction/confidential_mpt_convert_back.go @@ -0,0 +1,119 @@ +package transaction + +import "github.com/Peersyst/xrpl-go/xrpl/transaction/types" + +// ConfidentialMPTConvertBack converts confidential (encrypted) MPT balance back into +// public MPT balance. This requires a zero-knowledge proof (ZKProof) to verify that +// the holder has sufficient confidential balance without revealing the actual amounts. +// +// ```json +// +// { +// "TransactionType": "ConfidentialMPTConvertBack", +// "Fee": "10", +// "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", +// "MPTAmount": "1000", +// "HolderEncryptedAmount": "AABB...", +// "IssuerEncryptedAmount": "CCDD...", +// "BlindingFactor": "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", +// "BalanceCommitment": "EEFF...", +// "ZKProof": "1122..." +// } +// +// ``` +type ConfidentialMPTConvertBack struct { + BaseTx + // MPTokenIssuanceID identifies the MPTokenIssuance for which to convert balance. + MPTokenIssuanceID string + // MPTAmount is the amount of MPT to convert from confidential to public balance. + // Must be greater than 0. + MPTAmount types.MPTPlainAmount + // HolderEncryptedAmount is the encrypted amount being deducted from the holder's confidential balance. + // 66 bytes (two 33-byte compressed EC points), hex-encoded. + HolderEncryptedAmount string + // IssuerEncryptedAmount is the encrypted amount for the issuer's tracking purposes. + // 66 bytes (two 33-byte compressed EC points), hex-encoded. + IssuerEncryptedAmount string + // BlindingFactor is the 32-byte scalar value used to encrypt the amount. + // Used by validators to verify the ciphertexts match the plaintext MPTAmount. + BlindingFactor string + // AuditorEncryptedAmount is the encrypted amount for the auditor (if configured). (Optional) + // 66 bytes (two 33-byte compressed EC points), hex-encoded. + AuditorEncryptedAmount *string `json:",omitempty"` + // BalanceCommitment is the Pedersen commitment to the holder's remaining balance after conversion. + // Required for balance verification. + BalanceCommitment string + // ZKProof is a zero-knowledge proof proving the holder has sufficient confidential + // balance and that the conversion is valid. + ZKProof string +} + +// TxType returns the transaction type (ConfidentialMPTConvertBack). +func (*ConfidentialMPTConvertBack) TxType() TxType { + return ConfidentialMPTConvertBackTx +} + +// Flatten returns the flattened map of the ConfidentialMPTConvertBack transaction. +func (tx *ConfidentialMPTConvertBack) Flatten() FlatTransaction { + flattened := tx.BaseTx.Flatten() + + flattened["TransactionType"] = tx.TxType().String() + + flattened["MPTokenIssuanceID"] = tx.MPTokenIssuanceID + + flattened["MPTAmount"] = tx.MPTAmount.Flatten() + + flattened["HolderEncryptedAmount"] = tx.HolderEncryptedAmount + + flattened["IssuerEncryptedAmount"] = tx.IssuerEncryptedAmount + + flattened["BlindingFactor"] = tx.BlindingFactor + + if tx.AuditorEncryptedAmount != nil { + flattened["AuditorEncryptedAmount"] = *tx.AuditorEncryptedAmount + } + + flattened["BalanceCommitment"] = tx.BalanceCommitment + + flattened["ZKProof"] = tx.ZKProof + + return flattened +} + +// Validate validates the ConfidentialMPTConvertBack transaction. +func (tx *ConfidentialMPTConvertBack) Validate() (bool, error) { + ok, err := tx.BaseTx.Validate() + if err != nil || !ok { + return false, err + } + + if tx.MPTokenIssuanceID == "" { + return false, ErrConfidentialMPTInvalidIssuanceID + } + + if tx.MPTAmount == 0 { + return false, ErrConfidentialConvertBackInvalidAmount + } + + if !IsValidBlindingFactor(tx.BlindingFactor) { + return false, ErrConfidentialConvertBackInvalidBlindingFactor + } + + if !IsValidCiphertext(tx.HolderEncryptedAmount) || !IsValidCiphertext(tx.IssuerEncryptedAmount) { + return false, ErrConfidentialConvertBackInvalidCiphertext + } + + if tx.AuditorEncryptedAmount != nil && !IsValidCiphertext(*tx.AuditorEncryptedAmount) { + return false, ErrConfidentialConvertBackInvalidCiphertext + } + + if !IsValidCommitment(tx.BalanceCommitment) { + return false, ErrConfidentialConvertBackInvalidCommitment + } + + if !IsValidFixedHexBlob(tx.ZKProof, ConvertBackProofLen) { + return false, ErrConfidentialConvertBackInvalidProof + } + + return true, nil +} diff --git a/xrpl/transaction/confidential_mpt_convert_back_test.go b/xrpl/transaction/confidential_mpt_convert_back_test.go new file mode 100644 index 000000000..68fbecc96 --- /dev/null +++ b/xrpl/transaction/confidential_mpt_convert_back_test.go @@ -0,0 +1,391 @@ +package transaction + +import ( + "strings" + "testing" + + "github.com/Peersyst/xrpl-go/xrpl/transaction/types" + "github.com/stretchr/testify/require" +) + +// Test helpers for ConfidentialMPTConvertBack. +var ( + testConvertBackCiphertext1 = strings.Repeat("A1", 66) + testConvertBackCiphertext2 = strings.Repeat("B2", 66) + testConvertBackCiphertext3 = strings.Repeat("C3", 66) + testConvertBackCommitment = strings.Repeat("D4", 33) + testConvertBackBF = strings.Repeat("EF", 32) + testConvertBackProof = strings.Repeat("12", ConvertBackProofLen/2) +) + +func TestConfidentialMPTConvertBack_TxType(t *testing.T) { + tx := &ConfidentialMPTConvertBack{} + require.Equal(t, ConfidentialMPTConvertBackTx, tx.TxType()) +} + +func TestConfidentialMPTConvertBack_Flatten(t *testing.T) { + tests := []struct { + name string + tx *ConfidentialMPTConvertBack + expected FlatTransaction + }{ + { + name: "pass - without optional fields", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + expected: FlatTransaction{ + "Account": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "Fee": "12", + "TransactionType": "ConfidentialMPTConvertBack", + "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + "MPTAmount": "1000", + "HolderEncryptedAmount": testConvertBackCiphertext1, + "IssuerEncryptedAmount": testConvertBackCiphertext2, + "BlindingFactor": testConvertBackBF, + "BalanceCommitment": testConvertBackCommitment, + "ZKProof": testConvertBackProof, + }, + }, + { + name: "pass - with auditor encrypted amount", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(500), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + AuditorEncryptedAmount: types.HexBlob(testConvertBackCiphertext3), + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + expected: FlatTransaction{ + "Account": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "Fee": "12", + "TransactionType": "ConfidentialMPTConvertBack", + "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + "MPTAmount": "500", + "HolderEncryptedAmount": testConvertBackCiphertext1, + "IssuerEncryptedAmount": testConvertBackCiphertext2, + "BlindingFactor": testConvertBackBF, + "AuditorEncryptedAmount": testConvertBackCiphertext3, + "BalanceCommitment": testConvertBackCommitment, + "ZKProof": testConvertBackProof, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flattened := tt.tx.Flatten() + require.Equal(t, tt.expected, flattened) + }) + } +} + +func TestConfidentialMPTConvertBack_Validate(t *testing.T) { + tests := []struct { + name string + tx *ConfidentialMPTConvertBack + wantErr error + }{ + { + name: "pass - valid transaction", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + wantErr: nil, + }, + { + name: "pass - with valid AuditorEncryptedAmount", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + AuditorEncryptedAmount: types.HexBlob(testConvertBackCiphertext3), + BlindingFactor: testConvertBackBF, + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + wantErr: nil, + }, + { + name: "fail - empty MPTokenIssuanceID", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + wantErr: ErrConfidentialMPTInvalidIssuanceID, + }, + { + name: "fail - zero MPTAmount", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(0), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + wantErr: ErrConfidentialConvertBackInvalidAmount, + }, + { + name: "fail - invalid blinding factor", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: "short", + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + wantErr: ErrConfidentialConvertBackInvalidBlindingFactor, + }, + { + name: "fail - empty HolderEncryptedAmount", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: "", + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + wantErr: ErrConfidentialConvertBackInvalidCiphertext, + }, + { + name: "fail - empty IssuerEncryptedAmount", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: "", + BlindingFactor: testConvertBackBF, + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + wantErr: ErrConfidentialConvertBackInvalidCiphertext, + }, + { + name: "fail - short ZKProof", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + BalanceCommitment: testConvertBackCommitment, + ZKProof: strings.Repeat("AA", 10), + }, + wantErr: ErrConfidentialConvertBackInvalidProof, + }, + { + name: "fail - empty ZKProof", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + BalanceCommitment: testConvertBackCommitment, + ZKProof: "", + }, + wantErr: ErrConfidentialConvertBackInvalidProof, + }, + { + name: "fail - invalid AuditorEncryptedAmount", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + AuditorEncryptedAmount: types.HexBlob("not-hex!"), + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + wantErr: ErrConfidentialConvertBackInvalidCiphertext, + }, + { + name: "fail - empty BalanceCommitment", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + BalanceCommitment: "", + ZKProof: testConvertBackProof, + }, + wantErr: ErrConfidentialConvertBackInvalidCommitment, + }, + { + name: "fail - wrong length HolderEncryptedAmount", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: "AABB", + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + wantErr: ErrConfidentialConvertBackInvalidCiphertext, + }, + { + name: "fail - wrong length IssuerEncryptedAmount", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: "CCDD", + BlindingFactor: testConvertBackBF, + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + wantErr: ErrConfidentialConvertBackInvalidCiphertext, + }, + { + name: "fail - wrong length AuditorEncryptedAmount", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + AuditorEncryptedAmount: types.HexBlob("AABB"), + BalanceCommitment: testConvertBackCommitment, + ZKProof: testConvertBackProof, + }, + wantErr: ErrConfidentialConvertBackInvalidCiphertext, + }, + { + name: "fail - wrong length BalanceCommitment", + tx: &ConfidentialMPTConvertBack{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertBackTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testConvertBackCiphertext1, + IssuerEncryptedAmount: testConvertBackCiphertext2, + BlindingFactor: testConvertBackBF, + BalanceCommitment: "EEFF", + ZKProof: testConvertBackProof, + }, + wantErr: ErrConfidentialConvertBackInvalidCommitment, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.tx.Validate() + if tt.wantErr != nil { + require.EqualError(t, err, tt.wantErr.Error()) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/xrpl/transaction/confidential_mpt_convert_test.go b/xrpl/transaction/confidential_mpt_convert_test.go new file mode 100644 index 000000000..af2e5f821 --- /dev/null +++ b/xrpl/transaction/confidential_mpt_convert_test.go @@ -0,0 +1,373 @@ +package transaction + +import ( + "strings" + "testing" + + "github.com/Peersyst/xrpl-go/xrpl/transaction/types" + "github.com/stretchr/testify/require" +) + +// Test helper: 66-char hex string (33-byte compressed key). +var testCompressedKey = strings.Repeat("AB", 33) + +// Test helper: 128-char hex string (64-byte Schnorr proof). +var testSchnorrProof = strings.Repeat("CD", 64) + +// Test helper: 64-char hex string (32-byte blinding factor). +var testBlindingFactor = strings.Repeat("EF", 32) + +// Test helper: 132-char hex string (66-byte ElGamal ciphertext). +var testCiphertext = strings.Repeat("A1", 66) + +// Test helper: alternate 132-char hex string (66-byte ElGamal ciphertext). +var testCiphertext2 = strings.Repeat("B2", 66) + +// Test helper: third 132-char hex string (66-byte ElGamal ciphertext). +var testCiphertext3 = strings.Repeat("C3", 66) + +func TestConfidentialMPTConvert_TxType(t *testing.T) { + tx := &ConfidentialMPTConvert{} + require.Equal(t, ConfidentialMPTConvertTx, tx.TxType()) +} + +func TestConfidentialMPTConvert_Flatten(t *testing.T) { + tests := []struct { + name string + tx *ConfidentialMPTConvert + expected FlatTransaction + }{ + { + name: "pass - without optional fields", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: testBlindingFactor, + }, + expected: FlatTransaction{ + "Account": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "Fee": "12", + "TransactionType": "ConfidentialMPTConvert", + "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + "MPTAmount": "1000", + "HolderEncryptedAmount": testCiphertext, + "IssuerEncryptedAmount": testCiphertext2, + "BlindingFactor": testBlindingFactor, + }, + }, + { + name: "pass - with key and proof", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(500), + HolderEncryptionKey: types.EncryptionKey(testCompressedKey), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + AuditorEncryptedAmount: types.HexBlob(testCiphertext3), + BlindingFactor: testBlindingFactor, + ZKProof: types.HexBlob(testSchnorrProof), + }, + expected: FlatTransaction{ + "Account": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "Fee": "12", + "TransactionType": "ConfidentialMPTConvert", + "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + "MPTAmount": "500", + "HolderEncryptionKey": testCompressedKey, + "HolderEncryptedAmount": testCiphertext, + "IssuerEncryptedAmount": testCiphertext2, + "AuditorEncryptedAmount": testCiphertext3, + "BlindingFactor": testBlindingFactor, + "ZKProof": testSchnorrProof, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flattened := tt.tx.Flatten() + require.Equal(t, tt.expected, flattened) + }) + } +} + +func TestConfidentialMPTConvert_Validate(t *testing.T) { + tests := []struct { + name string + tx *ConfidentialMPTConvert + wantErr error + }{ + { + name: "pass - without key registration", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: testBlindingFactor, + }, + wantErr: nil, + }, + { + name: "pass - with key registration", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptionKey: types.EncryptionKey(testCompressedKey), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: testBlindingFactor, + ZKProof: types.HexBlob(testSchnorrProof), + }, + wantErr: nil, + }, + { + name: "pass - with valid AuditorEncryptedAmount", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + AuditorEncryptedAmount: types.HexBlob(testCiphertext3), + BlindingFactor: testBlindingFactor, + }, + wantErr: nil, + }, + { + name: "fail - empty MPTokenIssuanceID", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: testBlindingFactor, + }, + wantErr: ErrConfidentialMPTInvalidIssuanceID, + }, + { + name: "fail - key without proof", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptionKey: types.EncryptionKey(testCompressedKey), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: testBlindingFactor, + }, + wantErr: ErrConfidentialConvertKeyProofMismatch, + }, + { + name: "fail - proof without key", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: testBlindingFactor, + ZKProof: types.HexBlob(testSchnorrProof), + }, + wantErr: ErrConfidentialConvertKeyProofMismatch, + }, + { + name: "fail - invalid key length", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptionKey: types.HexBlob("AABB"), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: testBlindingFactor, + ZKProof: types.HexBlob(testSchnorrProof), + }, + wantErr: ErrConfidentialConvertInvalidKeyLength, + }, + { + name: "fail - invalid proof length", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptionKey: types.EncryptionKey(testCompressedKey), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: testBlindingFactor, + ZKProof: types.HexBlob("AABB"), + }, + wantErr: ErrConfidentialConvertInvalidProofLength, + }, + { + name: "fail - invalid blinding factor", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: "tooshort", + }, + wantErr: ErrConfidentialConvertInvalidBlindingFactor, + }, + { + name: "fail - empty HolderEncryptedAmount", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: "", + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: testBlindingFactor, + }, + wantErr: ErrConfidentialConvertInvalidCiphertext, + }, + { + name: "fail - invalid AuditorEncryptedAmount", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: testBlindingFactor, + AuditorEncryptedAmount: types.HexBlob("not-hex!"), + }, + wantErr: ErrConfidentialConvertInvalidCiphertext, + }, + { + name: "fail - empty IssuerEncryptedAmount", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: "", + BlindingFactor: testBlindingFactor, + }, + wantErr: ErrConfidentialConvertInvalidCiphertext, + }, + { + name: "fail - wrong length HolderEncryptedAmount", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: "AABB", + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: testBlindingFactor, + }, + wantErr: ErrConfidentialConvertInvalidCiphertext, + }, + { + name: "fail - wrong length IssuerEncryptedAmount", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: "AABB", + BlindingFactor: testBlindingFactor, + }, + wantErr: ErrConfidentialConvertInvalidCiphertext, + }, + { + name: "fail - wrong length AuditorEncryptedAmount", + tx: &ConfidentialMPTConvert{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTConvertTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MPTAmount: types.MPTPlainAmount(1000), + HolderEncryptedAmount: testCiphertext, + IssuerEncryptedAmount: testCiphertext2, + BlindingFactor: testBlindingFactor, + AuditorEncryptedAmount: types.HexBlob("AABB"), + }, + wantErr: ErrConfidentialConvertInvalidCiphertext, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.tx.Validate() + if tt.wantErr != nil { + require.EqualError(t, err, tt.wantErr.Error()) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/xrpl/transaction/confidential_mpt_merge_inbox.go b/xrpl/transaction/confidential_mpt_merge_inbox.go new file mode 100644 index 000000000..5cc5a42f4 --- /dev/null +++ b/xrpl/transaction/confidential_mpt_merge_inbox.go @@ -0,0 +1,56 @@ +package transaction + +// ConfidentialMPTMergeInbox merges the holder's confidential inbox balance (CB_IN) +// into their main confidential spending balance (CB_S). +// +// When confidential MPT is sent to a holder, it accumulates in their +// "inbox" balance. This transaction allows the holder to merge those +// incoming funds into their main "spending" balance so they can use them. +// +// This transaction is permissionless and requires no cryptographic proof because +// the holder is simply consolidating their own balances. +// +// ```json +// +// { +// "TransactionType": "ConfidentialMPTMergeInbox", +// "Fee": "10", +// "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000" +// } +// +// ``` +type ConfidentialMPTMergeInbox struct { + BaseTx + // MPTokenIssuanceID identifies the MPTokenIssuance for which to merge inbox balance. + MPTokenIssuanceID string +} + +// TxType returns the transaction type (ConfidentialMPTMergeInbox). +func (*ConfidentialMPTMergeInbox) TxType() TxType { + return ConfidentialMPTMergeInboxTx +} + +// Flatten returns the flattened map of the ConfidentialMPTMergeInbox transaction. +func (tx *ConfidentialMPTMergeInbox) Flatten() FlatTransaction { + flattened := tx.BaseTx.Flatten() + + flattened["TransactionType"] = tx.TxType().String() + + flattened["MPTokenIssuanceID"] = tx.MPTokenIssuanceID + + return flattened +} + +// Validate validates the ConfidentialMPTMergeInbox transaction. +func (tx *ConfidentialMPTMergeInbox) Validate() (bool, error) { + ok, err := tx.BaseTx.Validate() + if err != nil || !ok { + return false, err + } + + if tx.MPTokenIssuanceID == "" { + return false, ErrConfidentialMPTInvalidIssuanceID + } + + return true, nil +} diff --git a/xrpl/transaction/confidential_mpt_merge_inbox_test.go b/xrpl/transaction/confidential_mpt_merge_inbox_test.go new file mode 100644 index 000000000..2148b8bb0 --- /dev/null +++ b/xrpl/transaction/confidential_mpt_merge_inbox_test.go @@ -0,0 +1,89 @@ +package transaction + +import ( + "testing" + + "github.com/Peersyst/xrpl-go/xrpl/transaction/types" + "github.com/stretchr/testify/require" +) + +func TestConfidentialMPTMergeInbox_TxType(t *testing.T) { + tx := &ConfidentialMPTMergeInbox{} + require.Equal(t, ConfidentialMPTMergeInboxTx, tx.TxType()) +} + +func TestConfidentialMPTMergeInbox_Flatten(t *testing.T) { + tests := []struct { + name string + tx *ConfidentialMPTMergeInbox + expected FlatTransaction + }{ + { + name: "pass - all fields", + tx: &ConfidentialMPTMergeInbox{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + }, + expected: FlatTransaction{ + "Account": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "Fee": "12", + "TransactionType": "ConfidentialMPTMergeInbox", + "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flattened := tt.tx.Flatten() + require.Equal(t, tt.expected, flattened) + }) + } +} + +func TestConfidentialMPTMergeInbox_Validate(t *testing.T) { + tests := []struct { + name string + tx *ConfidentialMPTMergeInbox + wantErr error + }{ + { + name: "pass - valid transaction", + tx: &ConfidentialMPTMergeInbox{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTMergeInboxTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + }, + wantErr: nil, + }, + { + name: "fail - empty MPTokenIssuanceID", + tx: &ConfidentialMPTMergeInbox{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTMergeInboxTx, + Fee: types.XRPCurrencyAmount(12), + }, + MPTokenIssuanceID: "", + }, + wantErr: ErrConfidentialMPTInvalidIssuanceID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.tx.Validate() + if tt.wantErr != nil { + require.EqualError(t, err, tt.wantErr.Error()) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/xrpl/transaction/confidential_mpt_send.go b/xrpl/transaction/confidential_mpt_send.go new file mode 100644 index 000000000..a7d92e811 --- /dev/null +++ b/xrpl/transaction/confidential_mpt_send.go @@ -0,0 +1,141 @@ +package transaction + +import ( + addresscodec "github.com/Peersyst/xrpl-go/address-codec" + "github.com/Peersyst/xrpl-go/xrpl/transaction/types" +) + +// ConfidentialMPTSend sends confidential MPT from one account to another +// without revealing the transfer amount publicly. The transferred amount is +// credited to the receiver's inbox balance (CB_IN). +// +// The amount is encrypted for the sender, destination, issuer, and optionally +// an auditor. A zero-knowledge proof verifies that the sender has sufficient +// balance and that all encrypted amounts are consistent. +// +// ```json +// +// { +// "TransactionType": "ConfidentialMPTSend", +// "Fee": "10", +// "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", +// "Destination": "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", +// "SenderEncryptedAmount": "AABB...", +// "DestinationEncryptedAmount": "CCDD...", +// "IssuerEncryptedAmount": "EEFF...", +// "ZKProof": "1122...", +// "AmountCommitment": "3344...", +// "BalanceCommitment": "5566..." +// } +// +// ``` +type ConfidentialMPTSend struct { + BaseTx + // MPTokenIssuanceID identifies the MPTokenIssuance being transferred. + MPTokenIssuanceID string + // Destination is the account receiving the confidential MPT. + Destination types.Address + // SenderEncryptedAmount is the encrypted transfer amount for the sender. + // 66 bytes (two 33-byte compressed EC points), hex-encoded. + SenderEncryptedAmount string + // DestinationEncryptedAmount is the encrypted transfer amount for the destination. + // 66 bytes (two 33-byte compressed EC points), hex-encoded. + DestinationEncryptedAmount string + // IssuerEncryptedAmount is the encrypted transfer amount for the issuer's tracking purposes. + // 66 bytes (two 33-byte compressed EC points), hex-encoded. + IssuerEncryptedAmount string + // AuditorEncryptedAmount is the encrypted amount for the auditor (if configured). (Optional) + // 66 bytes (two 33-byte compressed EC points), hex-encoded. + AuditorEncryptedAmount *string `json:",omitempty"` + // ZKProof is a zero-knowledge proof proving the sender has sufficient balance + // and that all encrypted amounts are consistent. + ZKProof string + // AmountCommitment is the Pedersen commitment to the transfer amount. + // Required for proof verification. + AmountCommitment string + // BalanceCommitment is the Pedersen commitment to the sender's remaining balance after transfer. + BalanceCommitment string + // CredentialIDs is a set of Credential IDs that may be required for authorized transfers. (Optional) + CredentialIDs types.CredentialIDs `json:",omitempty"` +} + +// TxType returns the transaction type (ConfidentialMPTSend). +func (*ConfidentialMPTSend) TxType() TxType { + return ConfidentialMPTSendTx +} + +// Flatten returns the flattened map of the ConfidentialMPTSend transaction. +func (tx *ConfidentialMPTSend) Flatten() FlatTransaction { + flattened := tx.BaseTx.Flatten() + + flattened["TransactionType"] = tx.TxType().String() + + flattened["MPTokenIssuanceID"] = tx.MPTokenIssuanceID + + flattened["Destination"] = tx.Destination.String() + + flattened["SenderEncryptedAmount"] = tx.SenderEncryptedAmount + + flattened["DestinationEncryptedAmount"] = tx.DestinationEncryptedAmount + + flattened["IssuerEncryptedAmount"] = tx.IssuerEncryptedAmount + + if tx.AuditorEncryptedAmount != nil { + flattened["AuditorEncryptedAmount"] = *tx.AuditorEncryptedAmount + } + + flattened["ZKProof"] = tx.ZKProof + + flattened["AmountCommitment"] = tx.AmountCommitment + + flattened["BalanceCommitment"] = tx.BalanceCommitment + + if len(tx.CredentialIDs) > 0 { + flattened["CredentialIDs"] = tx.CredentialIDs.Flatten() + } + + return flattened +} + +// Validate validates the ConfidentialMPTSend transaction. +func (tx *ConfidentialMPTSend) Validate() (bool, error) { + ok, err := tx.BaseTx.Validate() + if err != nil || !ok { + return false, err + } + + if tx.MPTokenIssuanceID == "" { + return false, ErrConfidentialMPTInvalidIssuanceID + } + + if !addresscodec.IsValidAddress(tx.Destination.String()) { + return false, ErrConfidentialSendInvalidDestination + } + + if tx.Destination.String() == tx.Account.String() { + return false, ErrConfidentialSendSelfSend + } + + if !IsValidCiphertext(tx.SenderEncryptedAmount) || !IsValidCiphertext(tx.DestinationEncryptedAmount) || + !IsValidCiphertext(tx.IssuerEncryptedAmount) { + return false, ErrConfidentialSendInvalidCiphertext + } + + if tx.AuditorEncryptedAmount != nil && !IsValidCiphertext(*tx.AuditorEncryptedAmount) { + return false, ErrConfidentialSendInvalidCiphertext + } + + if !IsValidCommitment(tx.BalanceCommitment) || !IsValidCommitment(tx.AmountCommitment) { + return false, ErrConfidentialSendInvalidCommitment + } + + if !IsValidFixedHexBlob(tx.ZKProof, SendProofLen) { + return false, ErrConfidentialSendInvalidProof + } + + if tx.CredentialIDs != nil && !tx.CredentialIDs.IsValid() { + return false, ErrInvalidCredentialIDs + } + + return true, nil +} diff --git a/xrpl/transaction/confidential_mpt_send_test.go b/xrpl/transaction/confidential_mpt_send_test.go new file mode 100644 index 000000000..3fc41b7ac --- /dev/null +++ b/xrpl/transaction/confidential_mpt_send_test.go @@ -0,0 +1,510 @@ +package transaction + +import ( + "strings" + "testing" + + "github.com/Peersyst/xrpl-go/xrpl/transaction/types" + "github.com/stretchr/testify/require" +) + +// Test helpers for ConfidentialMPTSend. +var ( + testSendCiphertext1 = strings.Repeat("A1", 66) + testSendCiphertext2 = strings.Repeat("B2", 66) + testSendCiphertext3 = strings.Repeat("C3", 66) + testSendCiphertext4 = strings.Repeat("D4", 66) + testSendCommitment1 = strings.Repeat("E5", 33) + testSendCommitment2 = strings.Repeat("F6", 33) + testSendProof = strings.Repeat("11", SendProofLen/2) +) + +func TestConfidentialMPTSend_TxType(t *testing.T) { + tx := &ConfidentialMPTSend{} + require.Equal(t, ConfidentialMPTSendTx, tx.TxType()) +} + +func TestConfidentialMPTSend_Flatten(t *testing.T) { + tests := []struct { + name string + tx *ConfidentialMPTSend + expected FlatTransaction + }{ + { + name: "pass - without optional fields", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + expected: FlatTransaction{ + "Account": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "Fee": "12", + "TransactionType": "ConfidentialMPTSend", + "Destination": "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + "SenderEncryptedAmount": testSendCiphertext1, + "DestinationEncryptedAmount": testSendCiphertext2, + "IssuerEncryptedAmount": testSendCiphertext3, + "ZKProof": testSendProof, + "BalanceCommitment": testSendCommitment1, + "AmountCommitment": testSendCommitment2, + }, + }, + { + name: "pass - with optional fields", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + AuditorEncryptedAmount: types.HexBlob(testSendCiphertext4), + CredentialIDs: types.CredentialIDs{"AABBCCDD"}, + }, + expected: FlatTransaction{ + "Account": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "Fee": "12", + "TransactionType": "ConfidentialMPTSend", + "Destination": "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + "SenderEncryptedAmount": testSendCiphertext1, + "DestinationEncryptedAmount": testSendCiphertext2, + "IssuerEncryptedAmount": testSendCiphertext3, + "ZKProof": testSendProof, + "BalanceCommitment": testSendCommitment1, + "AmountCommitment": testSendCommitment2, + "AuditorEncryptedAmount": testSendCiphertext4, + "CredentialIDs": []string{"AABBCCDD"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flattened := tt.tx.Flatten() + require.Equal(t, tt.expected, flattened) + }) + } +} + +func TestConfidentialMPTSend_Validate(t *testing.T) { + tests := []struct { + name string + tx *ConfidentialMPTSend + wantErr error + }{ + { + name: "pass - valid transaction", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + wantErr: nil, + }, + { + name: "pass - with credential IDs", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + CredentialIDs: types.CredentialIDs{"AABBCCDD"}, + }, + wantErr: nil, + }, + { + name: "pass - with valid AuditorEncryptedAmount", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + AuditorEncryptedAmount: types.HexBlob(testSendCiphertext4), + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + wantErr: nil, + }, + { + name: "fail - empty MPTokenIssuanceID", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + wantErr: ErrConfidentialMPTInvalidIssuanceID, + }, + { + name: "fail - invalid Destination address", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "invalidAddress", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + wantErr: ErrConfidentialSendInvalidDestination, + }, + { + name: "fail - Destination same as Account", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + wantErr: ErrConfidentialSendSelfSend, + }, + { + name: "fail - empty SenderEncryptedAmount", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: "", + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + wantErr: ErrConfidentialSendInvalidCiphertext, + }, + { + name: "fail - empty DestinationEncryptedAmount", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: "", + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + wantErr: ErrConfidentialSendInvalidCiphertext, + }, + { + name: "fail - empty IssuerEncryptedAmount", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: "", + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + wantErr: ErrConfidentialSendInvalidCiphertext, + }, + { + name: "fail - short ZKProof", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: strings.Repeat("AA", 10), + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + wantErr: ErrConfidentialSendInvalidProof, + }, + { + name: "fail - empty ZKProof", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: "", + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + wantErr: ErrConfidentialSendInvalidProof, + }, + { + name: "fail - empty BalanceCommitment", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: "", + AmountCommitment: testSendCommitment2, + }, + wantErr: ErrConfidentialSendInvalidCommitment, + }, + { + name: "fail - empty AmountCommitment", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: "", + }, + wantErr: ErrConfidentialSendInvalidCommitment, + }, + { + name: "fail - invalid AuditorEncryptedAmount", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + AuditorEncryptedAmount: types.HexBlob("not-hex!"), + }, + wantErr: ErrConfidentialSendInvalidCiphertext, + }, + { + name: "fail - wrong length SenderEncryptedAmount", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: "AABB", + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + wantErr: ErrConfidentialSendInvalidCiphertext, + }, + { + name: "fail - wrong length DestinationEncryptedAmount", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: "BBCC", + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + }, + wantErr: ErrConfidentialSendInvalidCiphertext, + }, + { + name: "fail - wrong length AuditorEncryptedAmount", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + AuditorEncryptedAmount: types.HexBlob("AABB"), + }, + wantErr: ErrConfidentialSendInvalidCiphertext, + }, + { + name: "fail - wrong length BalanceCommitment", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: "EEFF", + AmountCommitment: testSendCommitment2, + }, + wantErr: ErrConfidentialSendInvalidCommitment, + }, + { + name: "fail - wrong length AmountCommitment", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: "FF11", + }, + wantErr: ErrConfidentialSendInvalidCommitment, + }, + { + name: "fail - invalid CredentialIDs", + tx: &ConfidentialMPTSend{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: ConfidentialMPTSendTx, + Fee: types.XRPCurrencyAmount(12), + }, + Destination: "rDgHn3T2P7eNAaoHh43iRudhAUjAHmDgEP", + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + SenderEncryptedAmount: testSendCiphertext1, + DestinationEncryptedAmount: testSendCiphertext2, + IssuerEncryptedAmount: testSendCiphertext3, + ZKProof: testSendProof, + BalanceCommitment: testSendCommitment1, + AmountCommitment: testSendCommitment2, + CredentialIDs: types.CredentialIDs{"not-hex!"}, + }, + wantErr: ErrInvalidCredentialIDs, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.tx.Validate() + if tt.wantErr != nil { + require.EqualError(t, err, tt.wantErr.Error()) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/xrpl/transaction/errors.go b/xrpl/transaction/errors.go index bcd79fbdf..dd0a69131 100644 --- a/xrpl/transaction/errors.go +++ b/xrpl/transaction/errors.go @@ -226,8 +226,8 @@ var ( ErrMPTIssuanceCreateDomainIDInvalid = errors.New("mptoken issuance create: DomainID must be a valid 64-character hexadecimal string") // ErrMPTIssuanceCreateDomainIDRequiresRequireAuth is returned when DomainID is set without enabling TfMPTRequireAuth flag. ErrMPTIssuanceCreateDomainIDRequiresRequireAuth = errors.New("mptoken issuance create: DomainID requires TfMPTRequireAuth flag to be set") - // ErrMPTIssuanceSetEmpty is returned when no operation is specified (no Flags, Holder, or DynamicMPT fields). - ErrMPTIssuanceSetEmpty = errors.New("mptoken issuance set: at least one of Flags, Holder, MutableFlags, MPTokenMetadata, TransferFee, or DomainID must be set") + // ErrMPTIssuanceSetEmpty is returned when no operation is specified (no Flags, Holder, DynamicMPT, or encryption key fields). + ErrMPTIssuanceSetEmpty = errors.New("mptoken issuance set: at least one of Flags, Holder, MutableFlags, MPTokenMetadata, TransferFee, DomainID, IssuerEncryptionKey, or AuditorEncryptionKey must be set") // ErrMPTIssuanceSetHolderMutuallyExclusive is returned when Holder is set together with DynamicMPT fields. ErrMPTIssuanceSetHolderMutuallyExclusive = errors.New("mptoken issuance set: Holder is mutually exclusive with MutableFlags/MPTokenMetadata/TransferFee/DomainID") // ErrMPTIssuanceSetFlagsMutuallyExclusive is returned when non-zero Flags are set together with DynamicMPT fields. @@ -240,6 +240,14 @@ var ( ErrMPTIssuanceSetTransferFeeWithClearCanTransfer = errors.New("mptoken issuance set: non-zero TransferFee cannot be set together with tmfMPTClearCanTransfer") // ErrMPTIssuanceSetDomainIDInvalid is returned when DomainID is not a valid 64-character hexadecimal string (and not empty). ErrMPTIssuanceSetDomainIDInvalid = errors.New("mptoken issuance set: DomainID must be a valid 64-character hexadecimal string or empty") + // ErrMPTIssuanceSetKeyConflict is returned when encryption keys are set together with Holder. + ErrMPTIssuanceSetKeyConflict = errors.New("mptoken issuance set: encryption keys cannot be set together with Holder") + // ErrMPTIssuanceSetAuditorRequiresIssuerKey is returned when AuditorEncryptionKey is set without IssuerEncryptionKey. + ErrMPTIssuanceSetAuditorRequiresIssuerKey = errors.New("mptoken issuance set: AuditorEncryptionKey requires IssuerEncryptionKey to be set") + // ErrMPTIssuanceSetKeysWithClearCanConfidentialAmount is returned when encryption keys are set together with tmfMPTClearCanConfidentialAmount. + ErrMPTIssuanceSetKeysWithClearCanConfidentialAmount = errors.New("mptoken issuance set: encryption keys cannot be set together with tmfMPTClearCanConfidentialAmount") + // ErrMPTIssuanceSetInvalidKeyLength is returned when an encryption key has an invalid length. + ErrMPTIssuanceSetInvalidKeyLength = errors.New("mptoken issuance set: encryption key must be 66 hex characters (33-byte compressed EC point)") // escrow @@ -548,6 +556,50 @@ var ( ErrVaultClawbackHolderRequired = errors.New("vaultClawback: Holder is required") // ErrVaultClawbackHolderInvalid is returned when Holder is not a valid XRPL address. ErrVaultClawbackHolderInvalid = errors.New("vaultClawback: Holder must be a valid XRPL address") + + // confidential mpt + + // ErrConfidentialMPTInvalidIssuanceID is returned when MPTokenIssuanceID is empty on a confidential MPT transaction. + ErrConfidentialMPTInvalidIssuanceID = errors.New("confidential MPT: MPTokenIssuanceID must not be empty") + // ErrConfidentialClawbackInvalidHolder is returned when the Holder address is invalid on a confidential MPT clawback. + ErrConfidentialClawbackInvalidHolder = errors.New("confidential MPT clawback: invalid Holder address") + // ErrConfidentialClawbackSelfClawback is returned when the Holder is the same as the Account on a confidential MPT clawback. + ErrConfidentialClawbackSelfClawback = errors.New("confidential MPT clawback: Holder cannot be the same as Account") + // ErrConfidentialClawbackInvalidAmount is returned when MPTAmount is not greater than 0 on a confidential MPT clawback. + ErrConfidentialClawbackInvalidAmount = errors.New("confidential MPT clawback: MPTAmount must be greater than 0") + // ErrConfidentialClawbackBadProof is returned when ZKProof does not match the required clawback proof length. + ErrConfidentialClawbackBadProof = errors.New("confidential MPT clawback: ZKProof must be 128 hex characters (64-byte compact clawback proof)") + + // ErrConfidentialConvertKeyProofMismatch is returned when HolderEncryptionKey and ZKProof are not both present or both absent. + ErrConfidentialConvertKeyProofMismatch = errors.New("confidential MPT convert: HolderEncryptionKey and ZKProof must both be present or both absent") + // ErrConfidentialConvertInvalidKeyLength is returned when HolderEncryptionKey is not 66 hex characters. + ErrConfidentialConvertInvalidKeyLength = errors.New("confidential MPT convert: HolderEncryptionKey must be 66 hex characters (33-byte compressed key)") + // ErrConfidentialConvertInvalidProofLength is returned when ZKProof is not 128 hex characters. + ErrConfidentialConvertInvalidProofLength = errors.New("confidential MPT convert: ZKProof must be 128 hex characters (64-byte Schnorr PoK)") + // ErrConfidentialConvertInvalidBlindingFactor is returned when BlindingFactor is not 64 hex characters. + ErrConfidentialConvertInvalidBlindingFactor = errors.New("confidential MPT convert: BlindingFactor must be 64 hex characters (32 bytes)") + // ErrConfidentialConvertInvalidCiphertext is returned when HolderEncryptedAmount or IssuerEncryptedAmount is not a valid 66-byte ciphertext. + ErrConfidentialConvertInvalidCiphertext = errors.New("confidential MPT convert: HolderEncryptedAmount, IssuerEncryptedAmount, and AuditorEncryptedAmount must be 132 hex characters (66-byte ElGamal ciphertext)") + // ErrConfidentialConvertBackInvalidAmount is returned when MPTAmount is not greater than 0 on a convert back. + ErrConfidentialConvertBackInvalidAmount = errors.New("confidential MPT convert back: MPTAmount must be greater than 0") + // ErrConfidentialConvertBackInvalidBlindingFactor is returned when BlindingFactor is not 64 hex characters on a convert back. + ErrConfidentialConvertBackInvalidBlindingFactor = errors.New("confidential MPT convert back: BlindingFactor must be 64 hex characters (32 bytes)") + // ErrConfidentialConvertBackInvalidCiphertext is returned when a ciphertext field is not a valid 66-byte ElGamal ciphertext on a convert back. + ErrConfidentialConvertBackInvalidCiphertext = errors.New("confidential MPT convert back: HolderEncryptedAmount, IssuerEncryptedAmount, and AuditorEncryptedAmount must be 132 hex characters (66-byte ElGamal ciphertext)") + // ErrConfidentialConvertBackInvalidCommitment is returned when BalanceCommitment is not a valid 33-byte commitment on a convert back. + ErrConfidentialConvertBackInvalidCommitment = errors.New("confidential MPT convert back: BalanceCommitment must be 66 hex characters (33-byte Pedersen commitment)") + // ErrConfidentialConvertBackInvalidProof is returned when ZKProof does not match the required convert back proof length. + ErrConfidentialConvertBackInvalidProof = errors.New("confidential MPT convert back: ZKProof must be 1632 hex characters (816-byte proof bundle)") + // ErrConfidentialSendInvalidDestination is returned when the Destination address is invalid on a confidential MPT send. + ErrConfidentialSendInvalidDestination = errors.New("confidential MPT send: invalid Destination address") + // ErrConfidentialSendSelfSend is returned when the Destination is the same as the Account on a confidential MPT send. + ErrConfidentialSendSelfSend = errors.New("confidential MPT send: Destination cannot be the same as Account") + // ErrConfidentialSendInvalidCiphertext is returned when a ciphertext field is not a valid 66-byte ElGamal ciphertext on a confidential MPT send. + ErrConfidentialSendInvalidCiphertext = errors.New("confidential MPT send: SenderEncryptedAmount, DestinationEncryptedAmount, IssuerEncryptedAmount, and AuditorEncryptedAmount must be 132 hex characters (66-byte ElGamal ciphertext)") + // ErrConfidentialSendInvalidCommitment is returned when a commitment field is not a valid 33-byte commitment on a confidential MPT send. + ErrConfidentialSendInvalidCommitment = errors.New("confidential MPT send: BalanceCommitment and AmountCommitment must be 66 hex characters (33-byte Pedersen commitment)") + // ErrConfidentialSendInvalidProof is returned when ZKProof does not match the required send proof length. + ErrConfidentialSendInvalidProof = errors.New("confidential MPT send: ZKProof must be 1892 hex characters (946-byte proof bundle)") ) // ErrAMMTradingFeeTooHigh is returned when the AMM trading fee exceeds the maximum allowed. diff --git a/xrpl/transaction/mptoken_issuance_create.go b/xrpl/transaction/mptoken_issuance_create.go index 2c0c12f21..285afe113 100644 --- a/xrpl/transaction/mptoken_issuance_create.go +++ b/xrpl/transaction/mptoken_issuance_create.go @@ -20,6 +20,8 @@ const ( TfMPTCanTransfer uint32 = 0x00000020 // TfMPTCanClawback if set, indicates that the issuer may use the Clawback transaction to claw back value from individual holders. TfMPTCanClawback uint32 = 0x00000040 + // TfMPTCanConfidentialAmount if set, indicates that confidential transfers are enabled for this token issuance. + TfMPTCanConfidentialAmount uint32 = 0x00000080 ) // MutableFlags constants for MPTokenIssuanceCreate. @@ -41,6 +43,8 @@ const ( TmfMPTCanMutateMetadata uint32 = 0x00010000 // TmfMPTCanMutateTransferFee allows the TransferFee to be changed after creation. TmfMPTCanMutateTransferFee uint32 = 0x00020000 + // TmfMPTCannotMutateCanConfidentialAmount prevents the CanConfidentialAmount property from being changed after creation. + TmfMPTCannotMutateCanConfidentialAmount uint32 = 0x00040000 ) // MPTokenIssuanceCreateMetadata represents the resulting metadata of a succeeded MPTokenIssuanceCreate transaction. @@ -169,6 +173,11 @@ func (m *MPTokenIssuanceCreate) SetMPTCanClawbackFlag() { m.Flags |= TfMPTCanClawback } +// SetMPTCanConfidentialAmountFlag sets the TfMPTCanConfidentialAmount flag to enable confidential transfers for this token issuance. +func (m *MPTokenIssuanceCreate) SetMPTCanConfidentialAmountFlag() { + m.Flags |= TfMPTCanConfidentialAmount +} + // setMutableFlag is a helper that initialises MutableFlags if nil and applies the given flag. func (m *MPTokenIssuanceCreate) setMutableFlag(f uint32) { if m.MutableFlags == nil { @@ -218,6 +227,11 @@ func (m *MPTokenIssuanceCreate) SetMPTCanMutateTransferFeeFlag() { m.setMutableFlag(TmfMPTCanMutateTransferFee) } +// SetMPTCannotMutateCanConfidentialAmountFlag prevents the CanConfidentialAmount property from being changed after creation. +func (m *MPTokenIssuanceCreate) SetMPTCannotMutateCanConfidentialAmountFlag() { + m.setMutableFlag(TmfMPTCannotMutateCanConfidentialAmount) +} + // Validate validates the MPTokenIssuanceCreate transaction ensuring all fields are correct. func (m *MPTokenIssuanceCreate) Validate() (bool, error) { ok, err := m.BaseTx.Validate() diff --git a/xrpl/transaction/mptoken_issuance_create_test.go b/xrpl/transaction/mptoken_issuance_create_test.go index f8b7d929f..a3468a7e6 100644 --- a/xrpl/transaction/mptoken_issuance_create_test.go +++ b/xrpl/transaction/mptoken_issuance_create_test.go @@ -331,6 +331,11 @@ func TestMPTokenIssuanceCreate_Flags(t *testing.T) { setFlag: (*MPTokenIssuanceCreate).SetMPTCanClawbackFlag, flagMask: TfMPTCanClawback, }, + { + name: "MPTCanConfidentialAmount", + setFlag: (*MPTokenIssuanceCreate).SetMPTCanConfidentialAmountFlag, + flagMask: TfMPTCanConfidentialAmount, + }, } for _, tt := range tests { @@ -361,7 +366,7 @@ func TestMPTokenIssuanceCreate_Flags(t *testing.T) { tt.setFlag(tx) } - expectedFlags := TfMPTCanLock | TfMPTRequireAuth | TfMPTCanEscrow | TfMPTCanTrade | TfMPTCanTransfer | TfMPTCanClawback + expectedFlags := TfMPTCanLock | TfMPTRequireAuth | TfMPTCanEscrow | TfMPTCanTrade | TfMPTCanTransfer | TfMPTCanClawback | TfMPTCanConfidentialAmount require.Equal(t, uint32(expectedFlags), tx.Flags) } @@ -411,6 +416,11 @@ func TestMPTokenIssuanceCreate_MutableFlags(t *testing.T) { setFlag: (*MPTokenIssuanceCreate).SetMPTCanMutateTransferFeeFlag, flagMask: TmfMPTCanMutateTransferFee, }, + { + name: "MPTCannotMutateCanConfidentialAmount", + setFlag: (*MPTokenIssuanceCreate).SetMPTCannotMutateCanConfidentialAmountFlag, + flagMask: TmfMPTCannotMutateCanConfidentialAmount, + }, } for _, tt := range tests { @@ -430,6 +440,6 @@ func TestMPTokenIssuanceCreate_MutableFlags(t *testing.T) { expectedMutableFlags := TmfMPTCanMutateCanLock | TmfMPTCanMutateRequireAuth | TmfMPTCanMutateCanEscrow | TmfMPTCanMutateCanTrade | TmfMPTCanMutateCanTransfer | TmfMPTCanMutateCanClawback | - TmfMPTCanMutateMetadata | TmfMPTCanMutateTransferFee + TmfMPTCanMutateMetadata | TmfMPTCanMutateTransferFee | TmfMPTCannotMutateCanConfidentialAmount require.Equal(t, expectedMutableFlags, *tx.MutableFlags) } diff --git a/xrpl/transaction/mptoken_issuance_set.go b/xrpl/transaction/mptoken_issuance_set.go index 10280ffd9..8aaedcc9b 100644 --- a/xrpl/transaction/mptoken_issuance_set.go +++ b/xrpl/transaction/mptoken_issuance_set.go @@ -41,6 +41,10 @@ const ( TmfMPTSetCanClawback uint32 = 0x00000400 // TmfMPTClearCanClawback clears the CanClawback flag. TmfMPTClearCanClawback uint32 = 0x00000800 + // TmfMPTSetCanConfidentialAmount sets the CanConfidentialAmount flag. + TmfMPTSetCanConfidentialAmount uint32 = 0x00001000 + // TmfMPTClearCanConfidentialAmount clears the CanConfidentialAmount flag. + TmfMPTClearCanConfidentialAmount uint32 = 0x00002000 ) // MPTokenIssuanceSet transaction is used to globally lock/unlock a MPTokenIssuance, @@ -72,6 +76,12 @@ type MPTokenIssuanceSet struct { TransferFee *uint16 `json:",omitempty"` // (Optional) Set or clear the flags which were marked as mutable. MutableFlags *uint32 `json:",omitempty"` + // (Optional) A 33-byte compressed ElGamal public key for the issuer. + // Required to use the confidential transfer feature. Must be 66 hex characters. + IssuerEncryptionKey *string `json:",omitempty"` + // (Optional) A 33-byte compressed ElGamal public key for an on-chain auditor. + // Must be 66 hex characters. Requires IssuerEncryptionKey to also be set. + AuditorEncryptionKey *string `json:",omitempty"` } // TxType returns the type of the transaction (MPTokenIssuanceSet). @@ -107,6 +117,14 @@ func (m *MPTokenIssuanceSet) Flatten() FlatTransaction { flattened["MutableFlags"] = *m.MutableFlags } + if m.IssuerEncryptionKey != nil { + flattened["IssuerEncryptionKey"] = *m.IssuerEncryptionKey + } + + if m.AuditorEncryptionKey != nil { + flattened["AuditorEncryptionKey"] = *m.AuditorEncryptionKey + } + return flattened } @@ -191,6 +209,16 @@ func (m *MPTokenIssuanceSet) SetMPTClearCanClawbackMutableFlag() { m.setMutableFlag(TmfMPTClearCanClawback) } +// SetMPTSetCanConfidentialAmountMutableFlag sets the CanConfidentialAmount mutable flag. +func (m *MPTokenIssuanceSet) SetMPTSetCanConfidentialAmountMutableFlag() { + m.setMutableFlag(TmfMPTSetCanConfidentialAmount) +} + +// SetMPTClearCanConfidentialAmountMutableFlag clears the CanConfidentialAmount mutable flag. +func (m *MPTokenIssuanceSet) SetMPTClearCanConfidentialAmountMutableFlag() { + m.setMutableFlag(TmfMPTClearCanConfidentialAmount) +} + // Validate validates the MPTokenIssuanceSet transaction ensuring all fields are correct. func (m *MPTokenIssuanceSet) Validate() (bool, error) { ok, err := m.BaseTx.Validate() @@ -222,9 +250,10 @@ func (m *MPTokenIssuanceSet) Validate() (bool, error) { } hasDynamicMPTFields := m.MutableFlags != nil || m.MPTokenMetadata != nil || m.TransferFee != nil + hasEncryptionKeys := m.IssuerEncryptionKey != nil || m.AuditorEncryptionKey != nil - // At least one operation must be specified (lock/unlock, holder lock/unlock, DynamicMPT mutation, or DomainID). - if m.Flags == 0 && m.Holder == nil && !hasDynamicMPTFields && m.DomainID == nil { + // At least one operation must be specified (lock/unlock, holder lock/unlock, DynamicMPT mutation, DomainID, or encryption keys). + if m.Flags == 0 && m.Holder == nil && !hasDynamicMPTFields && m.DomainID == nil && !hasEncryptionKeys { return false, ErrMPTIssuanceSetEmpty } @@ -233,6 +262,11 @@ func (m *MPTokenIssuanceSet) Validate() (bool, error) { return false, ErrMPTIssuanceSetHolderMutuallyExclusive } + // Encryption keys are mutually exclusive with Holder. + if m.Holder != nil && hasEncryptionKeys { + return false, ErrMPTIssuanceSetKeyConflict + } + // Non-zero Flags are mutually exclusive with DynamicMPT fields. if m.Flags != 0 && hasDynamicMPTFields { return false, ErrMPTIssuanceSetFlagsMutuallyExclusive @@ -271,18 +305,37 @@ func (m *MPTokenIssuanceSet) Validate() (bool, error) { return false, ErrMPTIssuanceSetTransferFeeWithClearCanTransfer } + // AuditorEncryptionKey requires IssuerEncryptionKey. + if m.AuditorEncryptionKey != nil && m.IssuerEncryptionKey == nil { + return false, ErrMPTIssuanceSetAuditorRequiresIssuerKey + } + + // Encryption keys cannot be uploaded while clearing the confidential amount flag. + if hasEncryptionKeys && m.MutableFlags != nil && flag.Contains(*m.MutableFlags, TmfMPTClearCanConfidentialAmount) { + return false, ErrMPTIssuanceSetKeysWithClearCanConfidentialAmount + } + + // Validate encryption key lengths (issuer and auditor keys must be 33-byte compressed). + if m.IssuerEncryptionKey != nil && !IsValidCompressedEncryptionKey(*m.IssuerEncryptionKey) { + return false, ErrMPTIssuanceSetInvalidKeyLength + } + if m.AuditorEncryptionKey != nil && !IsValidCompressedEncryptionKey(*m.AuditorEncryptionKey) { + return false, ErrMPTIssuanceSetInvalidKeyLength + } + return true, nil } // validateMutableFlagsNoConflict checks that no set/clear pair is active simultaneously. func validateMutableFlagsNoConflict(mf uint32) (bool, error) { - pairs := [6][2]uint32{ + pairs := [7][2]uint32{ {TmfMPTSetCanLock, TmfMPTClearCanLock}, {TmfMPTSetRequireAuth, TmfMPTClearRequireAuth}, {TmfMPTSetCanEscrow, TmfMPTClearCanEscrow}, {TmfMPTSetCanTrade, TmfMPTClearCanTrade}, {TmfMPTSetCanTransfer, TmfMPTClearCanTransfer}, {TmfMPTSetCanClawback, TmfMPTClearCanClawback}, + {TmfMPTSetCanConfidentialAmount, TmfMPTClearCanConfidentialAmount}, } for _, p := range pairs { if flag.Contains(mf, p[0]) && flag.Contains(mf, p[1]) { diff --git a/xrpl/transaction/mptoken_issuance_set_test.go b/xrpl/transaction/mptoken_issuance_set_test.go index 32713a2e3..40c585afd 100644 --- a/xrpl/transaction/mptoken_issuance_set_test.go +++ b/xrpl/transaction/mptoken_issuance_set_test.go @@ -13,6 +13,12 @@ func TestMPTokenIssuanceSet_TxType(t *testing.T) { require.Equal(t, MPTokenIssuanceSetTx, tx.TxType()) } +// validCompressedKey is a 66-char hex string representing a valid compressed EC public key for testing. +var validCompressedKey = strings.Repeat("AB", 33) + +// validUncompressedKey is a 128-char hex string representing a valid uncompressed EC public key for testing. +var validUncompressedKey = strings.Repeat("CD", 64) + func TestMPTokenIssuanceSet_Flatten(t *testing.T) { tests := []struct { name string @@ -137,6 +143,40 @@ func TestMPTokenIssuanceSet_Flatten(t *testing.T) { "DomainID": "A738A1E6E8505E1FC77BBB9FEF84FF9A9C609F2739E0F9573CDD6367100A0AA9", }, }, + { + name: "pass - with IssuerEncryptionKey", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + IssuerEncryptionKey: types.EncryptionKey(validCompressedKey), + }, + expected: FlatTransaction{ + "TransactionType": "MPTokenIssuanceSet", + "Account": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + "IssuerEncryptionKey": validCompressedKey, + }, + }, + { + name: "pass - with both encryption keys", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + IssuerEncryptionKey: types.EncryptionKey(validCompressedKey), + AuditorEncryptionKey: types.EncryptionKey(validCompressedKey), + }, + expected: FlatTransaction{ + "TransactionType": "MPTokenIssuanceSet", + "Account": "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + "MPTokenIssuanceID": "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + "IssuerEncryptionKey": validCompressedKey, + "AuditorEncryptionKey": validCompressedKey, + }, + }, } for _, tt := range tests { @@ -446,6 +486,142 @@ func TestMPTokenIssuanceSet_Validate(t *testing.T) { wantOk: false, wantErr: ErrMPTIssuanceSetHolderMutuallyExclusive, }, + { + name: "pass - valid with IssuerEncryptionKey compressed", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: MPTokenIssuanceSetTx, + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + IssuerEncryptionKey: types.EncryptionKey(validCompressedKey), + }, + wantOk: true, + wantErr: nil, + }, + { + name: "fail - IssuerEncryptionKey uncompressed not allowed", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: MPTokenIssuanceSetTx, + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + IssuerEncryptionKey: types.EncryptionKey(validUncompressedKey), + }, + wantOk: false, + wantErr: ErrMPTIssuanceSetInvalidKeyLength, + }, + { + name: "pass - valid with both encryption keys", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: MPTokenIssuanceSetTx, + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + IssuerEncryptionKey: types.EncryptionKey(validCompressedKey), + AuditorEncryptionKey: types.EncryptionKey(validCompressedKey), + }, + wantOk: true, + wantErr: nil, + }, + { + name: "pass - enable confidential with both encryption keys", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: MPTokenIssuanceSetTx, + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MutableFlags: types.MutableFlags(TmfMPTSetCanConfidentialAmount), + IssuerEncryptionKey: types.EncryptionKey(validCompressedKey), + AuditorEncryptionKey: types.EncryptionKey(validCompressedKey), + }, + wantOk: true, + wantErr: nil, + }, + { + name: "fail - AuditorEncryptionKey without IssuerEncryptionKey", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: MPTokenIssuanceSetTx, + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + AuditorEncryptionKey: types.EncryptionKey(validCompressedKey), + }, + wantOk: false, + wantErr: ErrMPTIssuanceSetAuditorRequiresIssuerKey, + }, + { + name: "fail - encryption keys with clear confidential amount", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: MPTokenIssuanceSetTx, + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MutableFlags: types.MutableFlags(TmfMPTClearCanConfidentialAmount), + IssuerEncryptionKey: types.EncryptionKey(validCompressedKey), + }, + wantOk: false, + wantErr: ErrMPTIssuanceSetKeysWithClearCanConfidentialAmount, + }, + { + name: "fail - IssuerEncryptionKey invalid length", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: MPTokenIssuanceSetTx, + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + IssuerEncryptionKey: types.EncryptionKey("AABB"), + }, + wantOk: false, + wantErr: ErrMPTIssuanceSetInvalidKeyLength, + }, + { + name: "fail - IssuerEncryptionKey invalid hex", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: MPTokenIssuanceSetTx, + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + IssuerEncryptionKey: types.EncryptionKey(strings.Repeat("GG", 33)), + }, + wantOk: false, + wantErr: ErrMPTIssuanceSetInvalidKeyLength, + }, + { + name: "fail - AuditorEncryptionKey invalid length", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: MPTokenIssuanceSetTx, + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + IssuerEncryptionKey: types.EncryptionKey(validCompressedKey), + AuditorEncryptionKey: types.EncryptionKey("AABB"), + }, + wantOk: false, + wantErr: ErrMPTIssuanceSetInvalidKeyLength, + }, + { + name: "fail - encryption keys with Holder", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: MPTokenIssuanceSetTx, + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + Holder: types.Holder("rNCFjv8Ek5oDrNiMJ3pw6eLLFtMjZLJnf2"), + IssuerEncryptionKey: types.EncryptionKey(validCompressedKey), + }, + wantOk: false, + wantErr: ErrMPTIssuanceSetKeyConflict, + }, { name: "fail - non-zero TransferFee with tmfMPTClearCanTransfer", tx: &MPTokenIssuanceSet{ @@ -566,6 +742,19 @@ func TestMPTokenIssuanceSet_Validate(t *testing.T) { wantOk: false, wantErr: ErrMPTIssuanceSetMutableFlagsConflict, }, + { + name: "fail - MutableFlags set/clear conflict CanConfidentialAmount", + tx: &MPTokenIssuanceSet{ + BaseTx: BaseTx{ + Account: "rLUEXYuLiQptky37CqLcm9USQpPiz5rkpD", + TransactionType: MPTokenIssuanceSetTx, + }, + MPTokenIssuanceID: "00070C4495F14B0E44F78A264E41713C64B5F89242540EE255534400000000000000", + MutableFlags: types.MutableFlags(TmfMPTSetCanConfidentialAmount | TmfMPTClearCanConfidentialAmount), + }, + wantOk: false, + wantErr: ErrMPTIssuanceSetMutableFlagsConflict, + }, } for _, tt := range tests { @@ -643,6 +832,16 @@ func TestMPTokenIssuanceSet_MutableFlags(t *testing.T) { setFlag: (*MPTokenIssuanceSet).SetMPTClearCanClawbackMutableFlag, flagMask: TmfMPTClearCanClawback, }, + { + name: "MPTSetCanConfidentialAmount", + setFlag: (*MPTokenIssuanceSet).SetMPTSetCanConfidentialAmountMutableFlag, + flagMask: TmfMPTSetCanConfidentialAmount, + }, + { + name: "MPTClearCanConfidentialAmount", + setFlag: (*MPTokenIssuanceSet).SetMPTClearCanConfidentialAmountMutableFlag, + flagMask: TmfMPTClearCanConfidentialAmount, + }, } for _, tt := range tests { @@ -665,7 +864,8 @@ func TestMPTokenIssuanceSet_MutableFlags(t *testing.T) { TmfMPTSetCanEscrow | TmfMPTClearCanEscrow | TmfMPTSetCanTrade | TmfMPTClearCanTrade | TmfMPTSetCanTransfer | TmfMPTClearCanTransfer | - TmfMPTSetCanClawback | TmfMPTClearCanClawback + TmfMPTSetCanClawback | TmfMPTClearCanClawback | + TmfMPTSetCanConfidentialAmount | TmfMPTClearCanConfidentialAmount require.Equal(t, expectedMutableFlags, *tx.MutableFlags) } diff --git a/xrpl/transaction/tx_type.go b/xrpl/transaction/tx_type.go index 21d8c8a30..ca4f466e6 100644 --- a/xrpl/transaction/tx_type.go +++ b/xrpl/transaction/tx_type.go @@ -19,6 +19,11 @@ const ( CheckCashTx TxType = "CheckCash" CheckCreateTx TxType = "CheckCreate" ClawbackTx TxType = "Clawback" + ConfidentialMPTClawbackTx TxType = "ConfidentialMPTClawback" + ConfidentialMPTConvertTx TxType = "ConfidentialMPTConvert" + ConfidentialMPTConvertBackTx TxType = "ConfidentialMPTConvertBack" + ConfidentialMPTMergeInboxTx TxType = "ConfidentialMPTMergeInbox" + ConfidentialMPTSendTx TxType = "ConfidentialMPTSend" CredentialAcceptTx TxType = "CredentialAccept" //nolint:gosec // G101 false positive, not credentials CredentialCreateTx TxType = "CredentialCreate" //nolint:gosec // G101 false positive, not credentials CredentialDeleteTx TxType = "CredentialDelete" //nolint:gosec // G101 false positive, not credentials diff --git a/xrpl/transaction/types/currency_amount.go b/xrpl/transaction/types/currency_amount.go index 7b75f2df6..915dc2989 100644 --- a/xrpl/transaction/types/currency_amount.go +++ b/xrpl/transaction/types/currency_amount.go @@ -167,6 +167,47 @@ func (a *XRPCurrencyAmount) UnmarshalText(data []byte) error { return nil } +// MPTPlainAmount represents a bare MPT token quantity as a uint64. +// Unlike MPTCurrencyAmount, it does not carry an issuance ID — use it for +// transaction fields where the MPTokenIssuanceID is a separate field. +type MPTPlainAmount uint64 + +// Uint64 returns the MPT amount as a uint64. +func (a MPTPlainAmount) Uint64() uint64 { + return uint64(a) +} + +func (a MPTPlainAmount) String() string { + return strconv.FormatUint(uint64(a), 10) +} + +// Kind returns the CurrencyKind for MPTPlainAmount. +func (MPTPlainAmount) Kind() CurrencyKind { + return MPT +} + +// Flatten returns the MPT amount as a decimal string. +func (a MPTPlainAmount) Flatten() any { + return a.String() +} + +// MarshalJSON serializes the MPT amount as a JSON string. +func (a MPTPlainAmount) MarshalJSON() ([]byte, error) { + s := strconv.FormatUint(uint64(a), 10) + return json.Marshal(s) +} + +// UnmarshalText parses a text representation into an MPTPlainAmount. +// encoding/json automatically calls UnmarshalText for JSON strings. +func (a *MPTPlainAmount) UnmarshalText(data []byte) error { + v, err := strconv.ParseUint(string(data), 10, 64) + if err != nil { + return err + } + *a = MPTPlainAmount(v) + return nil +} + // MPTCurrencyAmount represents a multi-party token currency amount with issuance ID and value. type MPTCurrencyAmount struct { MPTIssuanceID string `json:"mpt_issuance_id"` diff --git a/xrpl/transaction/types/currency_amount_test.go b/xrpl/transaction/types/currency_amount_test.go index f6fe8b091..80ad237d1 100644 --- a/xrpl/transaction/types/currency_amount_test.go +++ b/xrpl/transaction/types/currency_amount_test.go @@ -1,6 +1,7 @@ package types import ( + "encoding/json" "testing" "github.com/stretchr/testify/require" @@ -171,6 +172,39 @@ func TestMPTCurrencyAmount_Flatten(t *testing.T) { } } +func TestMPTPlainAmount_UnmarshalJSON(t *testing.T) { + t.Run("pass - valid JSON string", func(t *testing.T) { + var a MPTPlainAmount + err := json.Unmarshal([]byte(`"12345"`), &a) + require.NoError(t, err) + require.Equal(t, MPTPlainAmount(12345), a) + }) + + t.Run("pass - zero value", func(t *testing.T) { + var a MPTPlainAmount + err := json.Unmarshal([]byte(`"0"`), &a) + require.NoError(t, err) + require.Equal(t, MPTPlainAmount(0), a) + }) + + t.Run("fail - invalid string", func(t *testing.T) { + var a MPTPlainAmount + err := json.Unmarshal([]byte(`"notanumber"`), &a) + require.Error(t, err) + }) + + t.Run("pass - round trip", func(t *testing.T) { + original := MPTPlainAmount(9999) + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded MPTPlainAmount + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + require.Equal(t, original, decoded) + }) +} + func TestUnmarshalCurrencyAmount_MPT(t *testing.T) { testcases := []struct { name string diff --git a/xrpl/transaction/types/encryption_key.go b/xrpl/transaction/types/encryption_key.go new file mode 100644 index 000000000..5a1562486 --- /dev/null +++ b/xrpl/transaction/types/encryption_key.go @@ -0,0 +1,7 @@ +package types + +// EncryptionKey returns a pointer to the given encryption key string. +// Used as a convenience for setting optional encryption key fields in transactions. +func EncryptionKey(key string) *string { + return &key +} diff --git a/xrpl/transaction/types/hex_blob.go b/xrpl/transaction/types/hex_blob.go new file mode 100644 index 000000000..e17e4c9e7 --- /dev/null +++ b/xrpl/transaction/types/hex_blob.go @@ -0,0 +1,8 @@ +package types + +// HexBlob returns a pointer to the given hex-encoded blob string. +// Used as a convenience for setting optional hex blob fields in transactions +// (e.g. AuditorEncryptedAmount, ZKProof). +func HexBlob(value string) *string { + return &value +} diff --git a/xrpl/transaction/validations_xrpl_objects.go b/xrpl/transaction/validations_xrpl_objects.go index 2f34bfb09..f0605c77a 100644 --- a/xrpl/transaction/validations_xrpl_objects.go +++ b/xrpl/transaction/validations_xrpl_objects.go @@ -25,6 +25,8 @@ const ( StandardCurrencyCodeLen = 3 // Hex256Length is the number of characters in a 256-bit hexadecimal value. Hex256Length = 64 + // MPTIssuanceIDLength is the hex-encoded length of a 24-byte MPT issuance ID (48 hex chars). + MPTIssuanceIDLength = 48 ) // ************************* @@ -288,6 +290,11 @@ func IsLedgerEntryID(input string) bool { return IsHex256(input) } +// IsMPTIssuanceID checks if the given hex string is a valid 24-byte MPT issuance ID (48 hex chars). +func IsMPTIssuanceID(id string) bool { + return len(id) == MPTIssuanceIDLength && typecheck.IsHex(id) +} + // ValidateHexMetadata validates input is non-empty hex string of up to a certain length. // Returns true if the input is a valid non-empty hex string up to the specified length. func ValidateHexMetadata(input string, maxLength int) bool {