Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 11 additions & 18 deletions src/main/java/org/stellar/sdk/Address.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package org.stellar.sdk;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Arrays;
import lombok.EqualsAndHashCode;
import org.stellar.sdk.exception.UnexpectedException;
import org.stellar.sdk.xdr.ClaimableBalanceID;
import org.stellar.sdk.xdr.ClaimableBalanceIDType;
import org.stellar.sdk.xdr.ContractID;
Expand All @@ -14,8 +12,6 @@
import org.stellar.sdk.xdr.SCAddress;
import org.stellar.sdk.xdr.SCVal;
import org.stellar.sdk.xdr.SCValType;
import org.stellar.sdk.xdr.Uint256;
import org.stellar.sdk.xdr.Uint64;

/**
* Represents a single address in the Stellar network. An address can represent an account,
Expand Down Expand Up @@ -118,11 +114,11 @@ public static Address fromSCAddress(SCAddress scAddress) {
case SC_ADDRESS_TYPE_CONTRACT:
return fromContract(scAddress.getContractId().getContractID().getHash());
case SC_ADDRESS_TYPE_MUXED_ACCOUNT:
byte[] accountBytes = scAddress.getMuxedAccount().getEd25519().getUint256();
long id = scAddress.getMuxedAccount().getId().getUint64().getNumber().longValue();
byte[] rawBytes =
ByteBuffer.allocate(accountBytes.length + 8).put(accountBytes).putLong(id).array();
return fromMuxedAccount(rawBytes);
return fromMuxedAccount(
StrKey.toRawMuxedAccountStrKey(
new StrKey.RawMuxedAccountStrKeyParameter(
scAddress.getMuxedAccount().getEd25519(),
scAddress.getMuxedAccount().getId())));
case SC_ADDRESS_TYPE_CLAIMABLE_BALANCE:
if (scAddress.getClaimableBalanceId().getDiscriminant()
!= ClaimableBalanceIDType.CLAIMABLE_BALANCE_ID_TYPE_V0) {
Expand Down Expand Up @@ -174,16 +170,13 @@ public SCAddress toSCAddress() {
break;
case MUXED_ACCOUNT:
scAddress.setDiscriminant(org.stellar.sdk.xdr.SCAddressType.SC_ADDRESS_TYPE_MUXED_ACCOUNT);
Uint64 id;
Uint256 ed25519;
try {
id = Uint64.fromXdrByteArray(Arrays.copyOfRange(this.key, 32, 40));
ed25519 = Uint256.fromXdrByteArray(Arrays.copyOfRange(this.key, 0, 32));
} catch (IOException e) {
throw new UnexpectedException(e);
}
StrKey.RawMuxedAccountStrKeyParameter parameter =
StrKey.fromRawMuxedAccountStrKey(this.key);
MuxedEd25519Account muxedEd25519Account =
MuxedEd25519Account.builder().id(id).ed25519(ed25519).build();
MuxedEd25519Account.builder()
.id(parameter.getId())
.ed25519(parameter.getEd25519())
.build();
scAddress.setMuxedAccount(muxedEd25519Account);
break;
case CLAIMABLE_BALANCE:
Expand Down
32 changes: 8 additions & 24 deletions src/main/java/org/stellar/sdk/MuxedAccount.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,10 @@ public MuxedAccount(@NonNull String address) {
this.muxedId = null;
} else if (StrKey.isValidMed25519PublicKey(address)) {
byte[] rawMed25519 = StrKey.decodeMed25519PublicKey(address);
// first 32 bytes are the ed25519 public key
byte[] ed25519PublicKey = new byte[32];
System.arraycopy(rawMed25519, 0, ed25519PublicKey, 0, 32);
// the next 8 bytes are the multiplexing ID, it's an unsigned 64-bit integer
byte[] muxedIdBytes = new byte[8];
System.arraycopy(rawMed25519, 32, muxedIdBytes, 0, 8);
this.accountId = StrKey.encodeEd25519PublicKey(ed25519PublicKey);
this.muxedId = new BigInteger(1, muxedIdBytes);
StrKey.RawMuxedAccountStrKeyParameter parameter =
StrKey.fromRawMuxedAccountStrKey(rawMed25519);
this.accountId = StrKey.encodeEd25519PublicKey(parameter.getEd25519().getUint256());
this.muxedId = parameter.getId().getUint64().getNumber();
} else {
throw new IllegalArgumentException("Invalid address");
}
Expand All @@ -91,7 +87,10 @@ public String getAddress() {
if (muxedId == null) {
return accountId;
}
return StrKey.encodeMed25519PublicKey(getMuxedEd25519AccountBytes(toXdr().getMed25519()));
org.stellar.sdk.xdr.MuxedAccount.MuxedAccountMed25519 med25519 = toXdr().getMed25519();
return StrKey.encodeMed25519PublicKey(
StrKey.toRawMuxedAccountStrKey(
new StrKey.RawMuxedAccountStrKeyParameter(med25519.getEd25519(), med25519.getId())));
}

/**
Expand Down Expand Up @@ -134,19 +133,4 @@ public org.stellar.sdk.xdr.MuxedAccount toXdr() {
new Uint256(StrKey.decodeEd25519PublicKey(this.accountId))));
}
}

private static byte[] getMuxedEd25519AccountBytes(
org.stellar.sdk.xdr.MuxedAccount.MuxedAccountMed25519 muxedAccountMed25519) {
byte[] accountBytes = muxedAccountMed25519.getEd25519().getUint256();
byte[] idBytes = muxedAccountMed25519.getId().getUint64().getNumber().toByteArray();
byte[] idPaddedBytes = new byte[8];
int idNumBytesToCopy = Math.min(idBytes.length, 8);
int idCopyStartIndex = idBytes.length - idNumBytesToCopy;
System.arraycopy(
idBytes, idCopyStartIndex, idPaddedBytes, 8 - idNumBytesToCopy, idNumBytesToCopy);
byte[] result = new byte[accountBytes.length + idPaddedBytes.length];
System.arraycopy(accountBytes, 0, result, 0, accountBytes.length);
System.arraycopy(idPaddedBytes, 0, result, accountBytes.length, idPaddedBytes.length);
return result;
}
}
74 changes: 74 additions & 0 deletions src/main/java/org/stellar/sdk/StrKey.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Optional;
import lombok.NonNull;
import lombok.Value;
import org.stellar.sdk.exception.UnexpectedException;
import org.stellar.sdk.xdr.AccountID;
import org.stellar.sdk.xdr.CryptoKeyType;
Expand Down Expand Up @@ -709,6 +712,77 @@ private static boolean isInAlphabet(final byte[] arrayOctet) {
return true;
}

@Value
static class RawMuxedAccountStrKeyParameter {
@NonNull Uint256 ed25519;
@NonNull Uint64 id;
}

static byte[] toRawMuxedAccountStrKey(RawMuxedAccountStrKeyParameter parameter) {
// Get the 64-bit ID. This is the critical part of the explanation.
//
// THE KEY INSIGHT: Why using .longValue() is safe for a uint64
// --------------------------------------------------------------------
//
// The Goal: We need to get the 8-byte binary representation of an uint64.
// The Problem: A Java `long` is a *signed* 64-bit integer.
// - `uint64` range: 0 to 2^64 - 1
// - `long` range: -2^63 to 2^63 - 1
// A `uint64` can hold values larger than a `long`'s maximum positive value.
//
// The Solution: We focus on the underlying *bit pattern*, not the numerical interpretation.
//
// When we serialize data, we only care about the sequence of bits.
// - `getNumber()` returns a BigInteger, which correctly holds the full uint64 value.
// - `.longValue()` extracts the low-order 64 bits from the BigInteger. For an uint64, this is
// all its bits.
//
// Let's consider two cases:
//
// Case A: The uint64 value is small ( < 2^63 ).
// The most significant bit is 0. The `long` will have the same positive value, and the bit
// pattern is identical.
//
// Case B: The uint64 value is large ( >= 2^63 ).
// The most significant bit is 1. When converted to a `long`, Java interprets this bit as a
// sign bit,
// making the `long`'s numerical value negative. HOWEVER, the underlying 64-bit pattern in
// memory remains IDENTICAL
// to the original uint64's bit pattern.
//
// Example for Case B:
// - Let uint64 be 2^64 - 1 (all 64 bits are '1').
// - Its binary representation is 0xFFFF_FFFF_FFFF_FFFF.
// - `.longValue()` will produce a `long` with the value -1.
// - The binary representation of -1L in Java (using two's complement) is also
// 0xFFFF_FFFF_FFFF_FFFF.
//
// The bit patterns are the same!
//
// Since `ByteBuffer.putLong()` simply writes the 64-bit pattern of the given long into the
// buffer,
// it correctly serializes the original uint64 value into 8 bytes, regardless of whether Java
// interpreted the intermediate `long` as positive or negative.
long idLong = parameter.getId().getUint64().getNumber().longValue();
byte[] ed25519Bytes = parameter.getEd25519().getUint256();
return ByteBuffer.allocate(ed25519Bytes.length + 8).put(ed25519Bytes).putLong(idLong).array();
}

static RawMuxedAccountStrKeyParameter fromRawMuxedAccountStrKey(byte @NonNull [] data) {
if (data.length != 40) {
throw new IllegalArgumentException(
"Muxed account bytes must be 40 bytes long, got " + data.length);
}
ByteBuffer buffer = ByteBuffer.wrap(data);
byte[] ed25519Bytes = new byte[32];
buffer.get(ed25519Bytes);
byte[] idBytes = new byte[8];
buffer.get(idBytes);
Uint256 ed25519 = new Uint256(ed25519Bytes);
Uint64 id = new Uint64(new XdrUnsignedHyperInteger(new BigInteger(1, idBytes)));
return new RawMuxedAccountStrKeyParameter(ed25519, id);
}

enum VersionByte {
ACCOUNT_ID((byte) (6 << 3)), // G
MED25519_PUBLIC_KEY((byte) (12 << 3)), // M
Expand Down
85 changes: 85 additions & 0 deletions src/test/kotlin/org/stellar/sdk/StrKeyTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -344,4 +344,89 @@ class StrKeyTest :
}
exception.message shouldBe "Version byte is invalid"
}

context("toMuxedAccountBytes and fromMuxedAccountBytes") {
data class MuxedAccountTestCase(
val description: String,
val ed25519Hex: String,
val id: java.math.BigInteger,
)

context("round trip consistency") {
withData(
nameFn = { "should handle ${it.description}" },
MuxedAccountTestCase(
"ID = 0",
"5223d15964cb25b98d17dfc9cb954a4331617bbaa4e5dc144c87df0b8b3b47d9",
java.math.BigInteger.ZERO,
),
MuxedAccountTestCase(
"ID = 1",
"5223d15964cb25b98d17dfc9cb954a4331617bbaa4e5dc144c87df0b8b3b47d9",
java.math.BigInteger.ONE,
),
MuxedAccountTestCase(
"ID = 1234",
"5223d15964cb25b98d17dfc9cb954a4331617bbaa4e5dc144c87df0b8b3b47d9",
java.math.BigInteger.valueOf(1234L),
),
MuxedAccountTestCase(
"ID = Long.MAX_VALUE",
"5223d15964cb25b98d17dfc9cb954a4331617bbaa4e5dc144c87df0b8b3b47d9",
java.math.BigInteger.valueOf(Long.MAX_VALUE),
),
MuxedAccountTestCase(
"large ID > Long.MAX_VALUE",
"5223d15964cb25b98d17dfc9cb954a4331617bbaa4e5dc144c87df0b8b3b47d9",
java.math.BigInteger.valueOf(Long.MAX_VALUE).add(java.math.BigInteger.valueOf(12345L)),
),
MuxedAccountTestCase(
"max uint64 value (2^64 - 1)",
"5223d15964cb25b98d17dfc9cb954a4331617bbaa4e5dc144c87df0b8b3b47d9",
java.math.BigInteger("18446744073709551615"),
),
MuxedAccountTestCase(
"all-zero ed25519 key",
"0000000000000000000000000000000000000000000000000000000000000000",
java.math.BigInteger.valueOf(9999L),
),
MuxedAccountTestCase(
"all-FF ed25519 key",
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
java.math.BigInteger.valueOf(42L),
),
MuxedAccountTestCase(
"known test vector",
"2000757eeae583fc50dd669f97673acc25ec725823ac73faf6c7df31ad31e509",
java.math.BigInteger.valueOf(1234L),
),
) { testCase ->
val ed25519Bytes = Util.hexToBytes(testCase.ed25519Hex)
val ed25519 = org.stellar.sdk.xdr.Uint256(ed25519Bytes)
val id =
org.stellar.sdk.xdr.Uint64(org.stellar.sdk.xdr.XdrUnsignedHyperInteger(testCase.id))
val param = StrKey.RawMuxedAccountStrKeyParameter(ed25519, id)

val bytes = StrKey.toRawMuxedAccountStrKey(param)
bytes.size shouldBe 40

val decoded = StrKey.fromRawMuxedAccountStrKey(bytes)
decoded.ed25519.uint256 shouldBe ed25519Bytes
decoded.id.uint64.number shouldBe testCase.id
}
}

context("error handling") {
withData(
nameFn = { "should throw for ${it.first}" },
Pair("too short bytes (39)", 39),
Pair("too long bytes (41)", 41),
) { (_, length) ->
val invalidBytes = ByteArray(length)
val exception =
shouldThrow<IllegalArgumentException> { StrKey.fromRawMuxedAccountStrKey(invalidBytes) }
exception.message shouldBe "Muxed account bytes must be 40 bytes long, got $length"
}
}
}
})