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
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Tx3 SDK for Java

The Java SDK is the Java 21 client library for Tx3 protocols. It contains public contract values,
the signer interface, the typed error hierarchy, TII protocol loading and introspection, and an
asynchronous low-level TRP client.
the typed error hierarchy, TII protocol loading and introspection, an asynchronous low-level TRP
client, and extensible signer contracts with a raw-key Java Ed25519 signer and a Cardano mnemonic
signer derived at `m/1852'/1815'/0'/0/0`.

## Requirements

Expand Down Expand Up @@ -95,6 +96,25 @@ resolve time reports `ResolutionException`. Generated clients seed the same buil
`Tx3ClientBuilder.fromParts(...)`, bind statically known parties with `withPartyUnchecked`, and
construct canonical values with `argTagged`; that path retains no TII or parameter schema.

## Sign transactions

`Ed25519Signer` accepts only a 32-byte private-key seed and an address controlled by that key.
Mnemonic derivation belongs to `CardanoSigner`; both implementations sign the 32-byte
`txHashHex` from `SignRequest` and return a `VKEY` witness.

```java
import land.tx3.sdk.Address;
import land.tx3.sdk.CardanoSigner;
import land.tx3.sdk.SignRequest;

var signer = new CardanoSigner(mnemonic, new Address("addr_test1..."));
var witness = signer.sign(new SignRequest(txHashHex, txCborHex));
```

Key inputs and derived key material are kept in defensive copies and are never written to logs or
error messages. Invalid keys and malformed hashes use the SDK's typed `ValidationException`;
derivation, address-binding, and cryptographic failures use `SigningException`.

## Development

These are the canonical foundation checks:
Expand Down
84 changes: 84 additions & 0 deletions src/main/java/land/tx3/sdk/CardanoSigner.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package land.tx3.sdk;

import com.bloxbean.cardano.client.crypto.CryptoException;
import com.bloxbean.cardano.client.crypto.bip32.HdKeyPair;
import com.bloxbean.cardano.client.crypto.cip1852.CIP1852;
import com.bloxbean.cardano.client.crypto.cip1852.DerivationPath;
import com.bloxbean.cardano.client.crypto.config.CryptoConfiguration;
import java.util.Arrays;

/** A Cardano BIP32-Ed25519 signer derived at {@code m/1852'/1815'/0'/0/0}. */
public final class CardanoSigner implements Signer {
private final Address address;
private final byte[] privateKey;
private final byte[] publicKey;
private final byte[] chainCode;

/**
* Creates a Cardano signer from an Icarus/CIP-1852 mnemonic and its payment address.
*
* <p>Derived key material is defensively copied and is never included in errors or logs.
*
* @param mnemonic BIP-39 mnemonic phrase
* @param address Cardano address controlled by the derived payment key
* @throws ValidationException if the mnemonic is null or blank
* @throws SigningException if derivation fails or the address is not controlled by the key
*/
public CardanoSigner(String mnemonic, Address address) {
if (address == null) {
throw new ValidationException("address", "address must not be null");
}
if (mnemonic == null || mnemonic.isBlank()) {
throw new ValidationException("mnemonic", "mnemonic must not be null or blank");
}
this.address = address;

final HdKeyPair keyPair;
try {
keyPair =
new CIP1852()
.getKeyPairFromMnemonic(
mnemonic, DerivationPath.createExternalAddressDerivationPath());
} catch (RuntimeException exception) {
throw new SigningException(address, "CIP-1852 key derivation failed", exception);
}

this.privateKey = keyPair.getPrivateKey().getKeyData().clone();
this.publicKey = keyPair.getPublicKey().getKeyData().clone();
this.chainCode = keyPair.getPublicKey().getChainCode().clone();
SignerSupport.verifyAddressBinding(address, this.publicKey);
}

@Override
public Address address() {
return address;
}

/**
* Signs the request's 32-byte transaction hash with the derived BIP32-Ed25519 key.
*
* @throws ValidationException if the request is null
* @throws SigningException if signing fails
*/
@Override
public Witness sign(SignRequest request) {
if (request == null) {
throw new ValidationException("request", "sign request must not be null");
}
byte[] hash = SignerSupport.decodeHex(request.txHashHex(), "txHashHex", 32);
try {
byte[] signature =
CryptoConfiguration.INSTANCE.getSigningProvider().signExtended(hash, privateKey);
return new Witness(
SignerSupport.encodeHex(publicKey), SignerSupport.encodeHex(signature), WitnessType.VKEY);
} catch (CryptoException exception) {
throw new SigningException(address, "Cardano signing failed", exception);
} finally {
Arrays.fill(hash, (byte) 0);
}
}

byte[] chainCode() {
return chainCode.clone();
}
}
93 changes: 93 additions & 0 deletions src/main/java/land/tx3/sdk/Ed25519Signer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package land.tx3.sdk;

import com.bloxbean.cardano.client.crypto.KeyGenUtil;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Arrays;

/** A raw-key Ed25519 signer backed by the Java 21 Ed25519 provider. */
public final class Ed25519Signer implements Signer {
private static final byte[] PKCS8_SEED_PREFIX =
new byte[] {
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04,
0x20
};

private final Address address;
private final byte[] privateKey;
private final byte[] publicKey;

/**
* Creates a signer from a 32-byte Ed25519 private-key seed and its Cardano address.
*
* <p>The key is defensively copied and is never included in errors or logs. Mnemonic input is
* deliberately unsupported; use {@link CardanoSigner} for CIP-1852 mnemonic derivation.
*
* @param privateKey 32-byte Ed25519 private-key seed
* @param address Cardano address controlled by the key
* @throws ValidationException if the key is null or not 32 bytes
* @throws SigningException if the address is malformed or is not controlled by the key
*/
public Ed25519Signer(byte[] privateKey, Address address) {
if (address == null) {
throw new ValidationException("address", "address must not be null");
}
if (privateKey == null || privateKey.length != 32) {
throw new ValidationException("privateKey", "private key must be exactly 32 bytes");
}
this.privateKey = privateKey.clone();
this.address = address;
try {
this.publicKey = KeyGenUtil.getPublicKeyFromPrivateKey(this.privateKey).clone();
} catch (RuntimeException exception) {
throw new SigningException(address, "Ed25519 public-key derivation failed", exception);
}
SignerSupport.verifyAddressBinding(address, this.publicKey);
}

@Override
public Address address() {
return address;
}

/**
* Signs the request's 32-byte transaction hash with Ed25519.
*
* @throws ValidationException if the request is null
* @throws SigningException if the Java Ed25519 provider cannot sign the hash
*/
@Override
public Witness sign(SignRequest request) {
if (request == null) {
throw new ValidationException("request", "sign request must not be null");
}
byte[] hash = SignerSupport.decodeHex(request.txHashHex(), "txHashHex", 32);
try {
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(toJavaPrivateKey());
signer.update(hash);
return new Witness(
SignerSupport.encodeHex(publicKey),
SignerSupport.encodeHex(signer.sign()),
WitnessType.VKEY);
} catch (GeneralSecurityException exception) {
throw new SigningException(address, "Ed25519 signing failed", exception);
} finally {
Arrays.fill(hash, (byte) 0);
}
}

private PrivateKey toJavaPrivateKey() throws GeneralSecurityException {
byte[] encoded = new byte[PKCS8_SEED_PREFIX.length + privateKey.length];
System.arraycopy(PKCS8_SEED_PREFIX, 0, encoded, 0, PKCS8_SEED_PREFIX.length);
System.arraycopy(privateKey, 0, encoded, PKCS8_SEED_PREFIX.length, privateKey.length);
try {
return KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(encoded));
} finally {
Arrays.fill(encoded, (byte) 0);
}
}
}
11 changes: 4 additions & 7 deletions src/main/java/land/tx3/sdk/SignRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,11 @@ public record SignRequest(String txHashHex, String txCborHex) {
*
* @param txHashHex hexadecimal bound transaction hash
* @param txCborHex hexadecimal full transaction CBOR
* @throws ValidationException if either envelope is null
* @throws ValidationException if the hash is not exactly 32 bytes of hexadecimal or the CBOR is
* empty or malformed hexadecimal
*/
public SignRequest {
if (txHashHex == null) {
throw new ValidationException("txHashHex", "transaction hash must not be null");
}
if (txCborHex == null) {
throw new ValidationException("txCborHex", "transaction CBOR must not be null");
}
SignerSupport.decodeHex(txHashHex, "txHashHex", 32);
SignerSupport.decodeHex(txCborHex, "txCborHex", -1);
}
}
55 changes: 55 additions & 0 deletions src/main/java/land/tx3/sdk/SignerSupport.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package land.tx3.sdk;

import com.bloxbean.cardano.client.address.AddressProvider;
import java.util.HexFormat;

final class SignerSupport {
private static final HexFormat HEX = HexFormat.of();

private SignerSupport() {}

static byte[] decodeHex(String value, String field, int expectedBytes) {
if (value == null || value.isEmpty()) {
throw new ValidationException(field, field + " must not be null or empty");
}

final byte[] decoded;
try {
decoded = HEX.parseHex(value);
} catch (IllegalArgumentException exception) {
throw new ValidationException(field, field + " must contain only complete hexadecimal bytes");
}

if (expectedBytes >= 0 && decoded.length != expectedBytes) {
throw new ValidationException(field, field + " must be exactly " + expectedBytes + " bytes");
}
return decoded;
}

static String encodeHex(byte[] value) {
return HEX.formatHex(value);
}

static void verifyAddressBinding(Address address, byte[] publicKey) {
final com.bloxbean.cardano.client.address.Address parsed;
try {
parsed = new com.bloxbean.cardano.client.address.Address(address.value());
} catch (RuntimeException exception) {
throw new SigningException(
address, "signer address is not a valid Cardano address", exception);
}

try {
if (!parsed.isPubKeyHashInPaymentPart()) {
throw new SigningException(address, "signer address must contain a payment key credential");
}
if (!AddressProvider.verifyAddress(parsed, publicKey)) {
throw new SigningException(address, "signer key does not control the supplied address");
}
} catch (SigningException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new SigningException(address, "signer address cannot be bound to the key", exception);
}
}
}
25 changes: 25 additions & 0 deletions src/test/java/consumer/ExternalConsumerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
import land.tx3.sdk.ArgValue;
import land.tx3.sdk.ClientOptions;
import land.tx3.sdk.ParamType;
import land.tx3.sdk.SignRequest;
import land.tx3.sdk.Signer;
import land.tx3.sdk.Witness;
import land.tx3.sdk.WitnessType;
import org.junit.jupiter.api.Test;

/** Compile-time smoke test from outside the library package. */
Expand All @@ -24,4 +28,25 @@ void importsPublicEntryPoint() {
ArgValue.integer(BigInteger.valueOf(42)),
ArgEncoder.encode(new ParamType.Integer(), BigInteger.valueOf(42)));
}

@Test
void implementsSignerOutsideTheLibraryPackage() {
Signer signer =
new Signer() {
@Override
public Address address() {
return new Address("external-consumer-address");
}

@Override
public Witness sign(SignRequest request) {
return new Witness("00", "11", WitnessType.VKEY);
}
};

var request =
new SignRequest("0000000000000000000000000000000000000000000000000000000000000000", "80");
assertEquals("external-consumer-address", signer.address().value());
assertEquals(WitnessType.VKEY, signer.sign(request).type());
}
}
6 changes: 4 additions & 2 deletions src/test/java/land/tx3/sdk/ContractSerializationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ void serializesSignerContractsDeterministically() throws Exception {
assertEquals(
"{\"value\":\"addr_test1\"}", mapper.writeValueAsString(new Address("addr_test1")));
assertEquals(
"{\"txHashHex\":\"aabb\",\"txCborHex\":\"ccdd\"}",
mapper.writeValueAsString(new SignRequest("aabb", "ccdd")));
"{\"txHashHex\":\"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f\",\"txCborHex\":\"ccdd\"}",
mapper.writeValueAsString(
new SignRequest(
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "ccdd")));
assertEquals(
"{\"publicKeyHex\":\"0011\",\"signatureHex\":\"2233\",\"type\":\"vkey\"}",
mapper.writeValueAsString(new Witness("0011", "2233", WitnessType.VKEY)));
Expand Down
Loading
Loading