diff --git a/src/main/java/org/unicitylabs/sdk/api/CertificationResponse.java b/src/main/java/org/unicitylabs/sdk/api/CertificationResponse.java index c7f08ed6..b5fcf973 100644 --- a/src/main/java/org/unicitylabs/sdk/api/CertificationResponse.java +++ b/src/main/java/org/unicitylabs/sdk/api/CertificationResponse.java @@ -1,6 +1,7 @@ package org.unicitylabs.sdk.api; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import org.unicitylabs.sdk.serializer.UnicityObjectMapper; @@ -9,6 +10,7 @@ /** * Submit commitment response. */ +@JsonIgnoreProperties(ignoreUnknown = true) public class CertificationResponse { private final CertificationStatus status; diff --git a/src/main/java/org/unicitylabs/sdk/api/InclusionCertificate.java b/src/main/java/org/unicitylabs/sdk/api/InclusionCertificate.java index 36b5a587..0ef8f403 100644 --- a/src/main/java/org/unicitylabs/sdk/api/InclusionCertificate.java +++ b/src/main/java/org/unicitylabs/sdk/api/InclusionCertificate.java @@ -3,14 +3,11 @@ import org.unicitylabs.sdk.crypto.hash.DataHash; import org.unicitylabs.sdk.crypto.hash.DataHasher; import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.smt.radix.FinalizedBranch; -import org.unicitylabs.sdk.smt.radix.FinalizedLeafBranch; -import org.unicitylabs.sdk.smt.radix.FinalizedNodeBranch; -import org.unicitylabs.sdk.util.BitString; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; +import org.unicitylabs.sdk.smt.radix.SparseMerkleTreeRootNode; import org.unicitylabs.sdk.util.HexConverter; import org.unicitylabs.sdk.util.LongConverter; -import java.math.BigInteger; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -29,35 +26,23 @@ private InclusionCertificate(byte[] bitmap, List siblings) { this.siblings = siblings; } - public static InclusionCertificate create(FinalizedNodeBranch root, byte[] key) { - FinalizedBranch node = root; + public static InclusionCertificate create(SparseMerkleTreeRootNode root, byte[] key) { + Objects.requireNonNull(root, "root cannot be null"); + Objects.requireNonNull(key, "key cannot be null"); + if (key.length != 32) { + throw new IllegalArgumentException("Key must be 32 bytes long."); + } ArrayList siblings = new ArrayList<>(); byte[] bitmap = new byte[InclusionCertificate.BITMAP_SIZE]; - BigInteger keyPath = BitString.fromBytesReversedLSB(key).toBigInteger(); - - while (node != null) { - if (node instanceof FinalizedLeafBranch) { - FinalizedLeafBranch leaf = (FinalizedLeafBranch) node; - if (!Arrays.equals(leaf.getKey(), key)) { - throw new RuntimeException(String.format("Leaf not found for key: %s", HexConverter.encode(key))); - } - - return new InclusionCertificate(bitmap, siblings); - } - FinalizedNodeBranch nodeBranch = (FinalizedNodeBranch) node; - boolean isRight = keyPath.testBit(nodeBranch.getDepth()); - FinalizedBranch sibling = isRight ? nodeBranch.getLeft() : nodeBranch.getRight(); - if (sibling != null) { - bitmap[nodeBranch.getDepth() / 8] |= (byte) (1 << nodeBranch.getDepth() % 8); - siblings.add(sibling.getHash()); - } - - node = isRight ? nodeBranch.getRight() : nodeBranch.getLeft(); + for (SparseMerkleTreeRootNode.Sibling sibling : root.getPath(key)) { + int depth = sibling.getDepth(); + bitmap[depth >> 3] |= (byte) (0x80 >> (depth & 7)); + siblings.add(sibling.getHash()); } - throw new RuntimeException("Could not construct inclusion certificate: Invalid path"); + return new InclusionCertificate(bitmap, siblings); } public static InclusionCertificate decode(byte[] bytes) { @@ -113,12 +98,9 @@ public boolean verify(StateId leafKey, DataHash leafValue, DataHash expectedRoot .update(value) .digest(); - BigInteger keyPath = BitString.fromBytesReversedLSB(key).toBigInteger(); - BigInteger bitmapPath = BitString.fromBytesReversedLSB(this.bitmap).toBigInteger(); - int position = this.siblings.size(); for (int depth = InclusionCertificate.MAX_DEPTH; depth >= 0; depth--) { - if (!bitmapPath.testBit(depth)) continue; + if (SparseMerkleTreePathUtils.getBitAtDepth(this.bitmap, depth) == 0) continue; position -= 1; if (position < 0) return false; @@ -126,7 +108,7 @@ public boolean verify(StateId leafKey, DataHash leafValue, DataHash expectedRoot DataHash sibling = this.siblings.get(position); byte[] left, right; - if (keyPath.testBit(depth)) { + if (SparseMerkleTreePathUtils.getBitAtDepth(key, depth) == 1) { left = sibling.getData(); right = hash.getData(); } else { @@ -137,6 +119,7 @@ public boolean verify(StateId leafKey, DataHash leafValue, DataHash expectedRoot hash = new DataHasher(HashAlgorithm.SHA256) .update(new byte[]{0x01}) .update(LongConverter.encode(depth)) + .update(SparseMerkleTreePathUtils.regionFromKey(key, depth)) .update(left) .update(right) .digest(); diff --git a/src/main/java/org/unicitylabs/sdk/api/JsonRpcAggregatorClient.java b/src/main/java/org/unicitylabs/sdk/api/JsonRpcAggregatorClient.java index affbfd1c..e4e27a1c 100644 --- a/src/main/java/org/unicitylabs/sdk/api/JsonRpcAggregatorClient.java +++ b/src/main/java/org/unicitylabs/sdk/api/JsonRpcAggregatorClient.java @@ -3,8 +3,10 @@ import org.unicitylabs.sdk.api.jsonrpc.JsonRpcHttpTransport; import org.unicitylabs.sdk.util.HexConverter; +import java.net.URI; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; @@ -31,17 +33,46 @@ public JsonRpcAggregatorClient(String url) { /** - * Create aggregator client for destination url with api key. + * Create aggregator client for destination url with api key. When an api key is supplied the + * url must be {@code https}, so the key is never sent over plaintext. * * @param url destination url * @param apiKey api key * + * @throws IllegalArgumentException if an api key is supplied for a non-https url */ public JsonRpcAggregatorClient(String url, String apiKey) { - this.transport = new JsonRpcHttpTransport(Objects.requireNonNull(url, "url cannot be null")); + this(url, apiKey, false); + } + + /** + * Create aggregator client for destination url with api key. + * + * @param url destination url + * @param apiKey api key + * @param allowInsecureTransport when {@code true}, permit sending the api key over a non-https + * url (intended for local development and testing only) + * + * @throws IllegalArgumentException if an api key is supplied for a non-https url and + * {@code allowInsecureTransport} is {@code false} + */ + public JsonRpcAggregatorClient(String url, String apiKey, boolean allowInsecureTransport) { + Objects.requireNonNull(url, "url cannot be null"); + + if (apiKey != null && !allowInsecureTransport && !JsonRpcAggregatorClient.isHttps(url)) { + throw new IllegalArgumentException( + "API key must not be sent over plaintext HTTP; use an https url."); + } + + this.transport = new JsonRpcHttpTransport(url); this.apiKey = apiKey; } + private static boolean isHttps(String url) { + String scheme = URI.create(url).getScheme(); + return scheme != null && scheme.toLowerCase(Locale.ROOT).equals("https"); + } + /** * Submit a certification request for a transaction state transition. * diff --git a/src/main/java/org/unicitylabs/sdk/api/NetworkId.java b/src/main/java/org/unicitylabs/sdk/api/NetworkId.java index e5812e81..9b01d125 100644 --- a/src/main/java/org/unicitylabs/sdk/api/NetworkId.java +++ b/src/main/java/org/unicitylabs/sdk/api/NetworkId.java @@ -6,53 +6,58 @@ */ public final class NetworkId { - public static final NetworkId MAINNET = new NetworkId((short) 1, "MAINNET"); - public static final NetworkId TESTNET = new NetworkId((short) 2, "TESTNET"); - public static final NetworkId LOCAL = new NetworkId((short) 3, "LOCAL"); + public static final NetworkId MAINNET = new NetworkId(1, "MAINNET"); + public static final NetworkId TESTNET = new NetworkId(2, "TESTNET"); + public static final NetworkId LOCAL = new NetworkId(3, "LOCAL"); - private final short id; + private final int id; private final String name; - private NetworkId(short id, String name) { + private NetworkId(int id, String name) { this.id = id; this.name = name; } - private NetworkId(short id) { + private NetworkId(int id) { this(id, null); } /** - * Resolve a NetworkId from its numeric identifier. Returns the registered - * singleton for known ids; constructs a new (unnamed) instance for any - * other 16-bit value. + * Resolve a NetworkId from its raw 16-bit identifier. The whole 16-bit unsigned range + * {@code [1, 65535]} is valid; a {@code short} carries every bit pattern, so ids at or above + * {@code 0x8000} are supplied as negative shorts and interpreted as their unsigned value. + * Returns the registered singleton for known ids; constructs a new (unnamed) instance for any + * other value. * - * @param id numeric network identifier + * @param id raw 16-bit network identifier * @return NetworkId for the given identifier */ public static NetworkId fromId(short id) { - if (id <= 0) { - throw new IllegalArgumentException( - "Network identifier out of allowed 16-bit unsigned range: " + id + "."); + int value = id & 0xFFFF; + if (value == 0) { + throw new IllegalArgumentException("Network identifier cannot be zero."); } - if (id == MAINNET.id) { + + if (value == MAINNET.id) { return MAINNET; } - if (id == TESTNET.id) { + if (value == TESTNET.id) { return TESTNET; } - if (id == LOCAL.id) { + if (value == LOCAL.id) { return LOCAL; } - return new NetworkId(id); + return new NetworkId(value); } /** - * Get the numeric network identifier. + * Get the network identifier as its unsigned 16-bit value in {@code [1, 65535]}. Returned as an + * {@code int} so ids at or above {@code 0x8000} are not sign-extended when encoded or written as + * a number. * - * @return numeric identifier + * @return unsigned numeric identifier */ - public short getId() { + public int getId() { return this.id; } @@ -69,7 +74,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Short.hashCode(this.id); + return Integer.hashCode(this.id); } @Override diff --git a/src/main/java/org/unicitylabs/sdk/api/bft/RootTrustBase.java b/src/main/java/org/unicitylabs/sdk/api/bft/RootTrustBase.java index c489410d..6a12172b 100644 --- a/src/main/java/org/unicitylabs/sdk/api/bft/RootTrustBase.java +++ b/src/main/java/org/unicitylabs/sdk/api/bft/RootTrustBase.java @@ -8,8 +8,12 @@ import org.unicitylabs.sdk.serializer.UnicityObjectMapper; import org.unicitylabs.sdk.serializer.json.JsonSerializationException; import org.unicitylabs.sdk.serializer.json.LongAsStringSerializer; +import org.unicitylabs.sdk.util.HexConverter; import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -20,11 +24,12 @@ */ public class RootTrustBase { - private final long version; + private static final long VERSION = 1; + private final NetworkId networkId; private final long epoch; private final long epochStartRound; - private final Set rootNodes; + private final Map rootNodes; private final long quorumThreshold; private final byte[] stateHash; private final byte[] changeRecordHash; @@ -37,18 +42,46 @@ public class RootTrustBase { @JsonProperty("networkId") NetworkId networkId, @JsonProperty("epoch") long epoch, @JsonProperty("epochStartRound") long epochStartRound, - @JsonProperty("rootNodes") Set rootNodes, + @JsonProperty("rootNodes") List rootNodes, @JsonProperty("quorumThreshold") long quorumThreshold, @JsonProperty("stateHash") byte[] stateHash, @JsonProperty("changeRecordHash") byte[] changeRecordHash, @JsonProperty("previousEntryHash") byte[] previousEntryHash, @JsonProperty("signatures") Map signatures ) { - this.version = version; + if (version != RootTrustBase.VERSION) { + throw new IllegalArgumentException( + String.format("Unsupported RootTrustBase version: %s", version)); + } + + Objects.requireNonNull(rootNodes, "rootNodes cannot be null"); + + Map nodes = new LinkedHashMap<>(); + Set signingKeys = new HashSet<>(); + for (NodeInfo node : rootNodes) { + Objects.requireNonNull(node, "Trust base node cannot be null"); + if (nodes.putIfAbsent(node.getNodeId(), node) != null) { + throw new IllegalArgumentException( + String.format("Duplicate trust base node id: %s", node.getNodeId())); + } + if (!signingKeys.add(HexConverter.encode(node.getSigningKey()))) { + throw new IllegalArgumentException("Duplicate trust base signing key."); + } + } + + if (nodes.isEmpty()) { + throw new IllegalArgumentException("Trust base must contain at least one root node."); + } + + if (quorumThreshold < 1 || quorumThreshold > nodes.size()) { + throw new IllegalArgumentException( + "Trust base quorum threshold must be between 1 and the root node count."); + } + this.networkId = networkId; this.epoch = epoch; this.epochStartRound = epochStartRound; - this.rootNodes = Set.copyOf(rootNodes); + this.rootNodes = nodes; this.quorumThreshold = quorumThreshold; this.stateHash = Arrays.copyOf(stateHash, stateHash.length); this.changeRecordHash = changeRecordHash == null @@ -71,7 +104,7 @@ public class RootTrustBase { */ @JsonSerialize(using = LongAsStringSerializer.class) public long getVersion() { - return this.version; + return RootTrustBase.VERSION; } /** @@ -109,7 +142,17 @@ public long getEpochStartRound() { * @return root nodes */ public Set getRootNodes() { - return this.rootNodes; + return Set.copyOf(this.rootNodes.values()); + } + + /** + * Get root node by node id. + * + * @param nodeId node id + * @return node info, or {@code null} when no node with the given id exists + */ + public NodeInfo getRootNode(String nodeId) { + return this.rootNodes.get(nodeId); } /** @@ -210,6 +253,12 @@ public static class NodeInfo { @JsonProperty("sigKey") byte[] signingKey, @JsonProperty("stake") long stakedAmount ) { + Objects.requireNonNull(nodeId, "Node id cannot be null"); + Objects.requireNonNull(signingKey, "Node signing key cannot be null"); + if (stakedAmount <= 0) { + throw new IllegalArgumentException("Each trust base root node must have positive stake."); + } + this.nodeId = nodeId; this.signingKey = Arrays.copyOf(signingKey, signingKey.length); this.stakedAmount = stakedAmount; diff --git a/src/main/java/org/unicitylabs/sdk/api/bft/UnicitySeal.java b/src/main/java/org/unicitylabs/sdk/api/bft/UnicitySeal.java index 87263347..0356c53a 100644 --- a/src/main/java/org/unicitylabs/sdk/api/bft/UnicitySeal.java +++ b/src/main/java/org/unicitylabs/sdk/api/bft/UnicitySeal.java @@ -1,6 +1,7 @@ package org.unicitylabs.sdk.api.bft; import org.unicitylabs.sdk.api.NetworkId; +import org.unicitylabs.sdk.crypto.secp256k1.Signature; import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; import org.unicitylabs.sdk.serializer.cbor.CborDeserializer.CborTag; import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; @@ -162,7 +163,7 @@ public static UnicitySeal fromCbor(byte[] bytes) { CborDeserializer.decodeMap(data.get(7)).stream() .map(entry -> new SignatureEntry( CborDeserializer.decodeTextString(entry.getKey()), - CborDeserializer.decodeByteString(entry.getValue()) + Signature.fromCbor(entry.getValue()) )) .collect(Collectors.toSet()) ); @@ -191,7 +192,7 @@ public byte[] toCbor() { signatures.stream() .map(entry -> new CborMap.Entry( CborSerializer.encodeTextString(entry.getKey()), - CborSerializer.encodeByteString(entry.getSignature()) + entry.getSignature().toCbor() ) ) .collect(Collectors.toSet()) @@ -258,9 +259,9 @@ public String toString() { public static final class SignatureEntry { private final String key; - private final byte[] signature; + private final Signature signature; - SignatureEntry(String key, byte[] signature) { + SignatureEntry(String key, Signature signature) { this.key = key; this.signature = signature; } @@ -269,15 +270,15 @@ public String getKey() { return this.key; } - public byte[] getSignature() { - return Arrays.copyOf(this.signature, this.signature.length); + public Signature getSignature() { + return this.signature; } @Override public boolean equals(Object o) { if (!(o instanceof SignatureEntry)) return false; SignatureEntry that = (SignatureEntry) o; - return Objects.equals(this.key, that.key) && Objects.deepEquals(this.signature, that.signature); + return Objects.equals(this.key, that.key); } @Override @@ -287,7 +288,7 @@ public int hashCode() { @Override public String toString() { - return String.format("SignatureEntry{key=%s, signature=%s}", this.key, HexConverter.encode(this.signature)); + return String.format("SignatureEntry{key=%s, signature=%s}", this.key, this.signature); } } } diff --git a/src/main/java/org/unicitylabs/sdk/api/bft/verification/rule/UnicitySealQuorumSignaturesVerificationRule.java b/src/main/java/org/unicitylabs/sdk/api/bft/verification/rule/UnicitySealQuorumSignaturesVerificationRule.java index c0469ba1..ca7fc90e 100644 --- a/src/main/java/org/unicitylabs/sdk/api/bft/verification/rule/UnicitySealQuorumSignaturesVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/api/bft/verification/rule/UnicitySealQuorumSignaturesVerificationRule.java @@ -6,12 +6,12 @@ import org.unicitylabs.sdk.crypto.hash.DataHash; import org.unicitylabs.sdk.crypto.hash.DataHasher; import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.crypto.secp256k1.Signature; import org.unicitylabs.sdk.crypto.secp256k1.SigningService; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.util.verification.VerificationStatus; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; /** @@ -38,13 +38,13 @@ public static VerificationResult verify(RootTrustBase trustB int successful = 0; for (UnicitySeal.SignatureEntry entry : unicitySeal.getSignatures()) { String nodeId = entry.getKey(); - byte[] signature = entry.getSignature(); + Signature signature = entry.getSignature(); VerificationResult result = UnicitySealQuorumSignaturesVerificationRule.verifySignature( trustBase, nodeId, signature, - hash.getData() + hash ); results.add(result); @@ -73,14 +73,10 @@ public static VerificationResult verify(RootTrustBase trustB private static VerificationResult verifySignature( RootTrustBase trustBase, String nodeId, - byte[] signature, - byte[] hash + Signature signature, + DataHash hash ) { - NodeInfo node = trustBase.getRootNodes().stream() - .filter(n -> n.getNodeId().equals(nodeId)) - .findFirst() - .orElse(null); - + NodeInfo node = trustBase.getRootNode(nodeId); if (node == null) { return new VerificationResult<>( String.format("SignatureVerificationRule[%s]", nodeId), @@ -91,7 +87,7 @@ private static VerificationResult verifySignature( if (!SigningService.verifyWithPublicKey( hash, - Arrays.copyOf(signature, signature.length - 1), + signature, node.getSigningKey() )) { return new VerificationResult<>( diff --git a/src/main/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcHttpTransport.java b/src/main/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcHttpTransport.java index 879da981..d8341a3d 100644 --- a/src/main/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcHttpTransport.java +++ b/src/main/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcHttpTransport.java @@ -4,9 +4,14 @@ import okhttp3.*; import org.unicitylabs.sdk.serializer.UnicityObjectMapper; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.UUID; import java.util.concurrent.CompletableFuture; /** @@ -14,9 +19,18 @@ */ public class JsonRpcHttpTransport { + /** Default maximum response body size in bytes. */ + public static final int DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; + private static final MediaType MEDIA_TYPE_JSON = MediaType.get("application/json; charset=utf-8"); + private static final OkHttpClient DEFAULT_HTTP_CLIENT = new OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .build(); + private final String url; + private final int maxResponseBytes; private final OkHttpClient httpClient; /** @@ -25,8 +39,53 @@ public class JsonRpcHttpTransport { * @param url service URL */ public JsonRpcHttpTransport(String url) { - this.url = url; - this.httpClient = new OkHttpClient(); + this(url, JsonRpcHttpTransport.DEFAULT_MAX_RESPONSE_BYTES); + } + + /** + * JSON-RPC HTTP service constructor. + * + * @param url service URL + * @param maxResponseBytes maximum response body size in bytes + */ + public JsonRpcHttpTransport(String url, int maxResponseBytes) { + this(url, JsonRpcHttpTransport.DEFAULT_HTTP_CLIENT, maxResponseBytes); + } + + /** + * JSON-RPC HTTP service constructor with a caller-supplied HTTP client, to share a single + * connection and thread pool across transports. + * + * @param url service URL + * @param httpClient OkHttp client to use + */ + public JsonRpcHttpTransport(String url, OkHttpClient httpClient) { + this(url, httpClient, JsonRpcHttpTransport.DEFAULT_MAX_RESPONSE_BYTES); + } + + /** + * JSON-RPC HTTP service constructor with a caller-supplied HTTP client, to share a single + * connection and thread pool across transports. Redirect following is always disabled on the + * transport's client (via {@link OkHttpClient#newBuilder()}, which shares the supplied client's + * connection pool and dispatcher) so authentication headers are never replayed to a redirect + * target, regardless of the caller's redirect policy. + * + * @param url service URL + * @param httpClient OkHttp client to use + * @param maxResponseBytes maximum response body size in bytes + */ + public JsonRpcHttpTransport(String url, OkHttpClient httpClient, int maxResponseBytes) { + this.url = Objects.requireNonNull(url, "url cannot be null"); + if (maxResponseBytes <= 0) { + throw new IllegalArgumentException( + "maxResponseBytes must be positive, got " + maxResponseBytes + "."); + } + this.httpClient = Objects.requireNonNull(httpClient, "httpClient cannot be null") + .newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build(); + this.maxResponseBytes = maxResponseBytes; } /** @@ -58,16 +117,21 @@ public CompletableFuture request( Class resultType, Map> headers ) { + Objects.requireNonNull(method, "method cannot be null"); + Objects.requireNonNull(resultType, "resultType cannot be null"); + Objects.requireNonNull(headers, "headers cannot be null"); + CompletableFuture future = new CompletableFuture<>(); try { + JsonRpcRequest rpcRequest = new JsonRpcRequest(method, params); + UUID requestId = rpcRequest.getId(); + Request.Builder requestBuilder = new Request.Builder() .url(this.url) .post( RequestBody.create( - UnicityObjectMapper.JSON.writeValueAsString( - new JsonRpcRequest(method, params) - ), + UnicityObjectMapper.JSON.writeValueAsString(rpcRequest), JsonRpcHttpTransport.MEDIA_TYPE_JSON) ); @@ -86,32 +150,36 @@ public void onFailure(Call call, IOException e) { @Override public void onResponse(Call call, Response response) { try (ResponseBody body = response.body()) { + String bodyString = JsonRpcHttpTransport.this.readBounded(body); + if (!response.isSuccessful()) { - String error = body != null ? body.string() : ""; - future.completeExceptionally(new JsonRpcNetworkException(response.code(), error)); + future.completeExceptionally( + new JsonRpcNetworkException(response.code(), bodyString)); return; } JsonRpcResponse data = UnicityObjectMapper.JSON.readValue( - body != null ? body.string() : "", + bodyString, UnicityObjectMapper.JSON.getTypeFactory() .constructParametricType(JsonRpcResponse.class, resultType) ); if (data.getError() != null) { - future.completeExceptionally( - new JsonRpcNetworkException( - data.getError().getCode(), - data.getError().getMessage() - ) - ); + future.completeExceptionally(new JsonRpcNetworkException( + data.getError().getCode(), data.getError().getMessage())); + return; + } + + if (!requestId.equals(data.getId())) { + future.completeExceptionally(new IllegalArgumentException( + "JSON-RPC response id mismatch: expected " + requestId + ", got " + + data.getId() + ".")); return; } future.complete(data.getResult()); } catch (Exception e) { - future.completeExceptionally( - new RuntimeException("Failed to parse JSON-RPC response", e)); + future.completeExceptionally(e); } } }); @@ -121,4 +189,31 @@ public void onResponse(Call call, Response response) { return future; } + + /** + * Read the response body as a string, rejecting bodies larger than the configured limit before + * buffering the whole payload. + */ + private String readBounded(ResponseBody body) throws IOException { + if (body == null) { + return ""; + } + + try (InputStream in = body.byteStream()) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + long total = 0; + int read; + while ((read = in.read(buffer)) != -1) { + total += read; + if (total > this.maxResponseBytes) { + throw new IOException("JSON-RPC response exceeds the maximum allowed size."); + } + out.write(buffer, 0, read); + } + + // ByteArrayOutputStream.toString(Charset) is post-API-31; construct the String directly. + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + } } diff --git a/src/main/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcResponse.java b/src/main/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcResponse.java index 88a1f9cb..dd325e63 100644 --- a/src/main/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcResponse.java +++ b/src/main/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcResponse.java @@ -29,6 +29,11 @@ public class JsonRpcResponse { throw new IllegalArgumentException("Invalid JSON-RPC version: " + version); } + if ((result == null) == (error == null)) { + throw new IllegalArgumentException( + "JSON-RPC response must contain exactly one of result or error."); + } + this.version = version; this.result = result; this.error = error; diff --git a/src/main/java/org/unicitylabs/sdk/crypto/secp256k1/Signature.java b/src/main/java/org/unicitylabs/sdk/crypto/secp256k1/Signature.java index 05ad8473..1b3a9d24 100644 --- a/src/main/java/org/unicitylabs/sdk/crypto/secp256k1/Signature.java +++ b/src/main/java/org/unicitylabs/sdk/crypto/secp256k1/Signature.java @@ -70,8 +70,13 @@ public static Signature decode(byte[] input) { throw new IllegalArgumentException("Invalid signature bytes. Expected 65 bytes."); } - byte[] bytes = Arrays.copyOf(input, 64); int recovery = input[64] & 0xFF; // Ensure recovery is unsigned + if (recovery > 3) { + throw new IllegalArgumentException( + String.format("Invalid signature recovery id: %d.", recovery)); + } + + byte[] bytes = Arrays.copyOf(input, 64); return new Signature(bytes, recovery); } diff --git a/src/main/java/org/unicitylabs/sdk/crypto/secp256k1/SigningService.java b/src/main/java/org/unicitylabs/sdk/crypto/secp256k1/SigningService.java index 855b32d7..6112df5f 100644 --- a/src/main/java/org/unicitylabs/sdk/crypto/secp256k1/SigningService.java +++ b/src/main/java/org/unicitylabs/sdk/crypto/secp256k1/SigningService.java @@ -146,30 +146,30 @@ public Signature sign(DataHash hash) { * @return true if successful */ public boolean verify(DataHash hash, Signature signature) { - return verifyWithPublicKey(hash, signature.getBytes(), this.publicKey); + return SigningService.verifyWithPublicKey(hash, signature, this.publicKey); } /** - * Verify signature with public key. + * Verify secp256k1 signature against the given public key. * * @param hash data hash - * @param signature signature bytes + * @param signature compact signature bytes * @param publicKey public key * @return true if successful */ - public static boolean verifyWithPublicKey(DataHash hash, byte[] signature, byte[] publicKey) { - return SigningService.verifyWithPublicKey(hash.getData(), signature, publicKey); + public static boolean verify(DataHash hash, byte[] signature, byte[] publicKey) { + return SigningService.verify(hash.getData(), signature, publicKey); } /** - * Verify signature with public key and data hash bytes. + * Verify secp256k1 signature against the given public key and data hash bytes. * * @param hash hash bytes - * @param signature signature bytes + * @param signature compact signature bytes * @param publicKey public key * @return true if successful */ - public static boolean verifyWithPublicKey(byte[] hash, byte[] signature, byte[] publicKey) { + public static boolean verify(byte[] hash, byte[] signature, byte[] publicKey) { ECPoint pubPoint = EC_SPEC.getCurve().decodePoint(publicKey); ECPublicKeyParameters pubKey = new ECPublicKeyParameters(pubPoint, EC_DOMAIN_PARAMETERS); @@ -183,6 +183,25 @@ public static boolean verifyWithPublicKey(byte[] hash, byte[] signature, byte[] return verifier.verifySignature(hash, r, s); } + /** + * Verify a recoverable signature against an expected public key. Unlike {@link #verify(DataHash, + * byte[], byte[])}, this binds the signature's recovery byte: the public key is recovered from + * the signature and must equal {@code publicKey}, so a tampered recovery byte fails verification. + * + * @param hash data hash + * @param signature recoverable signature + * @param publicKey expected compressed public key + * @return true if the signature verifies and the recovered public key matches {@code publicKey} + */ + public static boolean verifyWithPublicKey(DataHash hash, Signature signature, byte[] publicKey) { + byte[] recoveredPublicKey = SigningService.recoverPublicKey(hash, signature); + if (recoveredPublicKey == null || !Arrays.equals(publicKey, recoveredPublicKey)) { + return false; + } + + return SigningService.verify(hash, signature.getBytes(), publicKey); + } + private byte[] toFixedLength(BigInteger value, int length) { byte[] bytes = value.toByteArray(); @@ -261,22 +280,43 @@ private static ECPoint decompressKey(BigInteger x, boolean ybit, ECCurve curve) } /** - * Verify signature with recovered public key - extract public key from signature. + * Recover the public key from the signature's recovery byte and verify the signature against + * {@code hash}. The recovered key defines the signer's identity; no expected key is supplied. * * @param hash data hash - * @param signature signature + * @param signature recoverable signature * @return true if successful */ - public static boolean verifySignatureWithRecoveredPublicKey(DataHash hash, Signature signature) { - // Extract r and s from signature - BigInteger r = new BigInteger(1, Arrays.copyOfRange(signature.getBytes(), 0, 32)); - BigInteger s = new BigInteger(1, Arrays.copyOfRange(signature.getBytes(), 32, 64)); - - ECPoint recovered = recoverFromSignature(signature.getRecovery(), r, s, hash.getData()); - if (recovered == null || !recovered.isValid()) { + public static boolean verifyWithRecoveredPublicKey(DataHash hash, Signature signature) { + byte[] recoveredPublicKey = SigningService.recoverPublicKey(hash, signature); + if (recoveredPublicKey == null) { return false; } - return verifyWithPublicKey(hash, signature.getBytes(), recovered.getEncoded(true)); + return SigningService.verify(hash, signature.getBytes(), recoveredPublicKey); + } + + /** + * Recover the compressed public key that produced {@code signature} over {@code hash}, using the + * signature's recovery byte. + * + * @param hash data hash + * @param signature recoverable signature + * @return recovered compressed public key, or {@code null} if the signature is not recoverable + */ + private static byte[] recoverPublicKey(DataHash hash, Signature signature) { + try { + BigInteger r = new BigInteger(1, Arrays.copyOfRange(signature.getBytes(), 0, 32)); + BigInteger s = new BigInteger(1, Arrays.copyOfRange(signature.getBytes(), 32, 64)); + + ECPoint recovered = recoverFromSignature(signature.getRecovery(), r, s, hash.getData()); + if (recovered == null || !recovered.isValid()) { + return null; + } + + return recovered.getEncoded(true); + } catch (Exception e) { + return null; + } } } \ No newline at end of file diff --git a/src/main/java/org/unicitylabs/sdk/payment/PaymentData.java b/src/main/java/org/unicitylabs/sdk/payment/PaymentData.java index 850abdac..7db66c7d 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/PaymentData.java +++ b/src/main/java/org/unicitylabs/sdk/payment/PaymentData.java @@ -1,8 +1,6 @@ package org.unicitylabs.sdk.payment; -import org.unicitylabs.sdk.payment.asset.Asset; - -import java.util.Set; +import org.unicitylabs.sdk.payment.asset.PaymentAssetCollection; /** * Represents payment payload data. @@ -11,9 +9,9 @@ public interface PaymentData { /** * Returns the assets included in this payment payload. * - * @return set of assets + * @return assets in canonical asset-id order */ - Set getAssets(); + PaymentAssetCollection getAssets(); /** * Encodes this payment payload into bytes. diff --git a/src/main/java/org/unicitylabs/sdk/payment/SplitAllocationProof.java b/src/main/java/org/unicitylabs/sdk/payment/SplitAllocationProof.java new file mode 100644 index 00000000..8e12893c --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/payment/SplitAllocationProof.java @@ -0,0 +1,257 @@ +package org.unicitylabs.sdk.payment; + +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.DataHasher; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; +import org.unicitylabs.sdk.serializer.cbor.CborSerializer; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; +import org.unicitylabs.sdk.smt.radixsum.SparseMerkleSumTreeRootNode; +import org.unicitylabs.sdk.util.BigIntegerConverter; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Split allocation inclusion proof for one output asset: the explicit-depth inclusion proof of a + * radix sparse Merkle sum tree — a leaf-to-root sequence of sibling entries + * {@code (depth, hash, sum)} with strictly decreasing depths. The asset identifier, output + * identifier, leaf data, leaf amount and root hash are not carried; the verifier supplies them. + * The empty proof is valid only when the allocation tree holds a single output leaf. + */ +public final class SplitAllocationProof { + + private static final int MAX_SIBLINGS = 256; + private static final int MAX_DEPTH = 255; + private static final BigInteger SUM_LIMIT = BigInteger.ONE.shiftLeft(256); + + private final List siblings; + + private SplitAllocationProof(List siblings) { + this.siblings = List.copyOf(siblings); + } + + /** + * Get the number of sibling entries in the proof. + * + * @return sibling count + */ + public int getLength() { + return this.siblings.size(); + } + + /** + * Build a split allocation proof for the leaf with the given key by walking the radix sum tree + * from the root to the leaf. + * + * @param root root of the asset's radix sum tree + * @param key 32-byte output token identifier + * @return inclusion proof for the key + * @throws IllegalArgumentException if the key is not present in the tree + */ + public static SplitAllocationProof create(SparseMerkleSumTreeRootNode root, byte[] key) { + Objects.requireNonNull(root, "root cannot be null"); + Objects.requireNonNull(key, "key cannot be null"); + if (key.length != 32) { + throw new IllegalArgumentException("Key must be 32 bytes long."); + } + + List siblings = new ArrayList<>(); + for (SparseMerkleSumTreeRootNode.Sibling sibling : root.getPath(key)) { + siblings.add(new Sibling(sibling.getDepth(), sibling.getHash(), sibling.getValue())); + } + + return new SplitAllocationProof(siblings); + } + + /** + * Create SplitAllocationProof from CBOR bytes. + * + * @param bytes CBOR bytes (an array of sibling entries) + * @return decoded proof + * @throws CborSerializationException on too many entries, an out-of-range or non-decreasing + * depth, or a non-positive or non-minimal sum + */ + public static SplitAllocationProof fromCbor(byte[] bytes) { + List entries = CborDeserializer.decodeArray(bytes); + if (entries.size() > SplitAllocationProof.MAX_SIBLINGS) { + throw new CborSerializationException( + "A split allocation proof has at most 256 sibling entries."); + } + + List siblings = new ArrayList<>(); + for (byte[] entry : entries) { + List fields = CborDeserializer.decodeArray(entry, 3); + + int depth = CborDeserializer.decodeUnsignedInteger(fields.get(0)).asListSize(); + if (depth > SplitAllocationProof.MAX_DEPTH) { + throw new CborSerializationException("Sibling depth must be in the range [0, 255]."); + } + if (!siblings.isEmpty() && depth >= siblings.get(siblings.size() - 1).depth) { + throw new CborSerializationException( + "Sibling depths must be strictly decreasing from the leaf to the root."); + } + + byte[] hash = CborDeserializer.decodeByteString(fields.get(1)); + if (hash.length != HashAlgorithm.SHA256.getLength()) { + throw new CborSerializationException("Sibling hash must be a SHA-256 digest."); + } + + BigInteger sum = CborDeserializer.decodeBigInteger(fields.get(2), 32); + if (sum.signum() <= 0) { + throw new CborSerializationException("Sibling sum must be strictly positive."); + } + + siblings.add(new Sibling( + depth, + new DataHash(HashAlgorithm.SHA256, hash), + sum + )); + } + + return new SplitAllocationProof(siblings); + } + + /** + * Reconstruct the root digest and sum for this proof by hashing from the leaf upward. + * + * @param key 32-byte output token identifier + * @param data 32-byte output commitment + * @param value strictly positive output amount for the asset + * @return reconstructed root digest and sum + * @throws IllegalArgumentException if the inputs are structurally invalid + * @throws ArithmeticException if the reconstructed sum overflows 256 bits + */ + public Root calculateRoot(byte[] key, byte[] data, BigInteger value) { + if (value.signum() <= 0 || value.compareTo(SplitAllocationProof.SUM_LIMIT) >= 0) { + throw new IllegalArgumentException("Value must be a positive 256-bit integer."); + } + + if (key.length != 32) { + throw new IllegalArgumentException("Key must be 32 bytes long."); + } + + if (data.length != 32) { + throw new IllegalArgumentException("Data must be 32 bytes long."); + } + + DataHash hash = new DataHasher(HashAlgorithm.SHA256) + .update(new byte[]{0x10}) + .update(key) + .update(data) + .update(BigIntegerConverter.encode(value, 32)) + .digest(); + BigInteger sum = value; + + for (Sibling sibling : this.siblings) { + BigInteger nextSum = sum.add(sibling.sum); + if (nextSum.compareTo(SplitAllocationProof.SUM_LIMIT) >= 0) { + throw new ArithmeticException("Reconstructed sum overflows 256 bits."); + } + + boolean isRight = SparseMerkleTreePathUtils.getBitAtDepth(key, sibling.depth) == 1; + DataHash leftHash = isRight ? sibling.hash : hash; + BigInteger leftValue = isRight ? sibling.sum : sum; + DataHash rightHash = isRight ? hash : sibling.hash; + BigInteger rightValue = isRight ? sum : sibling.sum; + + hash = new DataHasher(HashAlgorithm.SHA256) + .update(new byte[]{0x11, (byte) sibling.depth}) + .update(leftHash.getData()) + .update(BigIntegerConverter.encode(leftValue, 32)) + .update(rightHash.getData()) + .update(BigIntegerConverter.encode(rightValue, 32)) + .digest(); + sum = nextSum; + } + + return new Root(hash, sum); + } + + /** + * Verify this proof by reconstructing the root from the leaf upward and checking it against the + * expected root digest and target sum. + * + * @param key 32-byte output token identifier + * @param data 32-byte output commitment + * @param value strictly positive output amount for the asset + * @param expectedRootHash expected RSMST root digest from the manifest + * @param expectedSum expected reconstructed root sum + * @return true if the proof reconstructs to the root and target sum + */ + public boolean verify(byte[] key, byte[] data, BigInteger value, DataHash expectedRootHash, + BigInteger expectedSum) { + try { + Root root = this.calculateRoot(key, data, value); + return root.getHash().equals(expectedRootHash) && root.getSum().equals(expectedSum); + } catch (RuntimeException e) { + return false; + } + } + + /** + * Convert SplitAllocationProof to CBOR bytes. + * + * @return CBOR bytes + */ + public byte[] toCbor() { + return CborSerializer.encodeArray( + this.siblings.stream() + .map(sibling -> CborSerializer.encodeArray( + CborSerializer.encodeUnsignedInteger(sibling.depth), + CborSerializer.encodeByteString(sibling.hash.getData()), + CborSerializer.encodeBigInteger(sibling.sum) + )) + .toArray(byte[][]::new) + ); + } + + /** + * Reconstructed RSMST root: digest and total sum. + */ + public static final class Root { + private final DataHash hash; + private final BigInteger sum; + + private Root(DataHash hash, BigInteger sum) { + this.hash = hash; + this.sum = sum; + } + + /** + * Get the reconstructed root digest. + * + * @return root digest + */ + public DataHash getHash() { + return this.hash; + } + + /** + * Get the reconstructed root sum. + * + * @return root sum + */ + public BigInteger getSum() { + return this.sum; + } + } + + /** + * One sibling entry of an explicit-depth RSMST inclusion proof. + */ + private static final class Sibling { + private final int depth; + private final DataHash hash; + private final BigInteger sum; + + private Sibling(int depth, DataHash hash, BigInteger sum) { + this.depth = depth; + this.hash = hash; + this.sum = sum; + } + } +} diff --git a/src/main/java/org/unicitylabs/sdk/payment/SplitAssetProof.java b/src/main/java/org/unicitylabs/sdk/payment/SplitAssetProof.java deleted file mode 100644 index 29654c28..00000000 --- a/src/main/java/org/unicitylabs/sdk/payment/SplitAssetProof.java +++ /dev/null @@ -1,115 +0,0 @@ -package org.unicitylabs.sdk.payment; - -import org.unicitylabs.sdk.payment.asset.AssetId; -import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.smt.plain.SparseMerkleTreePath; -import org.unicitylabs.sdk.smt.sum.SparseMerkleSumTreePath; - -import java.util.List; -import java.util.Objects; - -/** - * Proof material for one split reason entry. - */ -public final class SplitAssetProof { - private final AssetId assetId; - private final SparseMerkleTreePath aggregationPath; - private final SparseMerkleSumTreePath assetTreePath; - - private SplitAssetProof( - AssetId assetId, - SparseMerkleTreePath aggregationPath, - SparseMerkleSumTreePath assetTreePath - ) { - this.assetId = assetId; - this.aggregationPath = aggregationPath; - this.assetTreePath = assetTreePath; - } - - /** - * Get asset id referenced by this proof. - * - * @return asset id - */ - public AssetId getAssetId() { - return this.assetId; - } - - /** - * Get sparse merkle path in the aggregation tree. - * - * @return aggregation path - */ - public SparseMerkleTreePath getAggregationPath() { - return this.aggregationPath; - } - - /** - * Get sparse merkle sum tree path for the asset tree. - * - * @return asset tree path - */ - public SparseMerkleSumTreePath getAssetTreePath() { - return this.assetTreePath; - } - - /** - * Create split reason proof. - * - * @param assetId asset id - * @param aggregationPath aggregation path - * @param assetTreePath asset tree path - * - * @return split reason proof - */ - public static SplitAssetProof create( - AssetId assetId, - SparseMerkleTreePath aggregationPath, - SparseMerkleSumTreePath assetTreePath - ) { - return new SplitAssetProof(assetId, aggregationPath, assetTreePath); - } - - /** - * Deserialize split reason proof from CBOR bytes. - * - * @param bytes CBOR bytes - * - * @return split reason proof - */ - public static SplitAssetProof fromCbor(byte[] bytes) { - List data = CborDeserializer.decodeArray(bytes, 3); - - return new SplitAssetProof( - AssetId.fromCbor(data.get(0)), - SparseMerkleTreePath.fromCbor(data.get(1)), - SparseMerkleSumTreePath.fromCbor(data.get(2)) - ); - } - - /** - * Serialize split reason proof to CBOR bytes. - * - * @return CBOR bytes - */ - public byte[] toCbor() { - return CborSerializer.encodeArray( - this.assetId.toCbor(), - this.aggregationPath.toCbor(), - this.assetTreePath.toCbor() - ); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof SplitAssetProof)) return false; - SplitAssetProof that = (SplitAssetProof) o; - return Objects.equals(this.assetId, that.assetId); - } - - @Override - public int hashCode() { - return Objects.hashCode(this.assetId); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/payment/SplitManifest.java b/src/main/java/org/unicitylabs/sdk/payment/SplitManifest.java new file mode 100644 index 00000000..93e8c4ff --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/payment/SplitManifest.java @@ -0,0 +1,101 @@ +package org.unicitylabs.sdk.payment; + +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; +import org.unicitylabs.sdk.serializer.cbor.CborSerializer; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Split manifest: CBOR semantic tag 39046 applied directly to an array of per-asset sum-tree root + * hashes, positionally aligned with the source token's assets in canonical order. The certified + * burn transfer carries the manifest as its auxiliary data, and the burn reason is the SHA-256 of + * its canonical encoding. + * + *

Roots are exposed as {@link DataHash} instances, so the digest length is enforced by the + * hash type. On the wire each root is encoded as its raw digest bytes (no algorithm imprint); + * decoding reconstructs SHA-256 hashes. + */ +public final class SplitManifest { + public static final long CBOR_TAG = 39046; + + private final List roots; + + private SplitManifest(List roots) { + this.roots = List.copyOf(roots); + } + + /** + * Get the per-asset RSMST root hashes. + * + * @return root hashes + */ + public List getRoots() { + return this.roots; + } + + /** + * Create a SplitManifest from per-asset root hashes. + * + * @param roots RSMST root hashes in canonical source-asset order + * @return new manifest + * @throws IllegalArgumentException if {@code roots} is empty + */ + public static SplitManifest create(List roots) { + Objects.requireNonNull(roots, "roots cannot be null"); + + if (roots.isEmpty()) { + throw new IllegalArgumentException("Split manifest must contain at least one root."); + } + + return new SplitManifest(roots); + } + + /** + * Create SplitManifest from CBOR bytes. + * + * @param bytes CBOR bytes + * @return decoded manifest + * @throws CborSerializationException on wrong tag or malformed roots + */ + public static SplitManifest fromCbor(byte[] bytes) { + CborDeserializer.CborTag tag = CborDeserializer.decodeTag(bytes); + if (tag.getTag() != SplitManifest.CBOR_TAG) { + throw new CborSerializationException( + String.format("Invalid CBOR tag for SplitManifest: %s", tag.getTag())); + } + + List roots = CborDeserializer.decodeArray(tag.getData()).stream() + .map(root -> { + byte[] digest = CborDeserializer.decodeByteString(root); + if (digest.length != HashAlgorithm.SHA256.getLength()) { + throw new CborSerializationException( + "Each split manifest root must be a SHA-256 digest."); + } + + return new DataHash(HashAlgorithm.SHA256, digest); + }) + .collect(Collectors.toList()); + + return SplitManifest.create(roots); + } + + /** + * Convert SplitManifest to CBOR bytes. + * + * @return CBOR bytes + */ + public byte[] toCbor() { + return CborSerializer.encodeTag( + SplitManifest.CBOR_TAG, + CborSerializer.encodeArray( + this.roots.stream() + .map(root -> CborSerializer.encodeByteString(root.getData())) + .toArray(byte[][]::new)) + ); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/payment/SplitMintJustification.java b/src/main/java/org/unicitylabs/sdk/payment/SplitMintJustification.java index c8d582b6..739452b5 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/SplitMintJustification.java +++ b/src/main/java/org/unicitylabs/sdk/payment/SplitMintJustification.java @@ -1,31 +1,38 @@ package org.unicitylabs.sdk.payment; +import org.unicitylabs.sdk.crypto.hash.DataHasher; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.predicate.EncodedPredicate; import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; import org.unicitylabs.sdk.serializer.cbor.CborSerializer; import org.unicitylabs.sdk.transaction.Token; +import org.unicitylabs.sdk.transaction.TokenId; +import org.unicitylabs.sdk.transaction.TokenSalt; -import java.util.HashSet; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; /** - * Mint justification for a split-output token, carrying the burn token of the source and the - * inclusion proofs that link each output asset back to the burned source aggregation tree. + * Split mint reason: CBOR semantic tag 39044 applied to the two-element array + * {@code [burned source token, split allocation proofs]}, where the proofs appear in canonical + * output-asset order, one per asset the minted output carries. */ public final class SplitMintJustification { public static final long CBOR_TAG = 39044; + /** ASCII domain separator {@code UNICITY_SPLIT_OUTPUT} for the split output commitment. */ + private static final byte[] SPLIT_OUTPUT = + "UNICITY_SPLIT_OUTPUT".getBytes(StandardCharsets.US_ASCII); + private final Token token; - private final List proofs; + private final List proofs; - private SplitMintJustification( - Token token, - List proofs - ) { + private SplitMintJustification(Token token, List proofs) { this.token = token; - this.proofs = proofs; + this.proofs = List.copyOf(proofs); } /** @@ -38,60 +45,104 @@ public Token getToken() { } /** - * Get the inclusion proofs supporting this split mint justification. + * Get the allocation proofs supporting this split mint justification. * * @return proofs */ - public List getProofs() { + public List getProofs() { return this.proofs; } /** - * Create a split mint justification. + * Calculate the sum-tree leaf data {@code d_j} for a split output: a commitment that binds an + * allocation leaf to its output mint transaction. Every term is a CBOR byte string except the + * network identifier, which is an unsigned integer. The mint reason is deliberately excluded — + * it embeds the proofs, which are derived from this value. * - * @param token burn token of the source token being split - * @param proofs inclusion proofs supporting split eligibility + * @param token token which is going to be burnt; its identifier, network and token type are + * bound into the commitment (a split preserves the source network and token type, so the + * output's equal them) + * @param recipient output recipient predicate + * @param salt output mint salt + * @param tokenId output token identifier + * @param data exact output auxiliary-payload byte string, or {@code null} + * @return raw 32-byte commitment digest + */ + public static byte[] calculateLeafData( + Token token, + EncodedPredicate recipient, + TokenSalt salt, + TokenId tokenId, + byte[] data + ) { + Objects.requireNonNull(token, "token cannot be null"); + Objects.requireNonNull(recipient, "recipient cannot be null"); + Objects.requireNonNull(salt, "salt cannot be null"); + Objects.requireNonNull(tokenId, "tokenId cannot be null"); + + return new DataHasher(HashAlgorithm.SHA256) + .update( + CborSerializer.encodeArray( + CborSerializer.encodeByteString(SplitMintJustification.SPLIT_OUTPUT), + CborSerializer.encodeByteString(token.getId().getBytes()), + CborSerializer.encodeUnsignedInteger( + token.getGenesis().getNetworkId().getId()), + CborSerializer.encodeByteString(recipient.toCbor()), + salt.toCbor(), + tokenId.toCbor(), + token.getType().toCbor(), + CborSerializer.encodeNullable(data, CborSerializer::encodeByteString) + ) + ) + .digest() + .getData(); + } + + /** + * Create a SplitMintJustification. * - * @return split mint justification + * @param token burned source token (including its certified burn transfer) + * @param proofs allocation proofs in canonical output-asset order + * @return new justification + * @throws IllegalArgumentException if {@code proofs} is empty */ - public static SplitMintJustification create(Token token, List proofs) { + public static SplitMintJustification create(Token token, List proofs) { Objects.requireNonNull(token, "token cannot be null"); Objects.requireNonNull(proofs, "proofs cannot be null"); if (proofs.isEmpty()) { - throw new IllegalArgumentException("proofs cannot be empty"); - } - - if (new HashSet<>(proofs).size() != proofs.size()) { - throw new IllegalArgumentException("proofs contain duplicate asset ids"); + throw new IllegalArgumentException("proofs cannot be empty."); } - return new SplitMintJustification(token, List.copyOf(proofs)); + return new SplitMintJustification(token, proofs); } /** - * Deserialize split mint justification from CBOR bytes. + * Create SplitMintJustification from CBOR bytes. * * @param bytes CBOR bytes - * - * @return split mint justification + * @return decoded justification + * @throws CborSerializationException on wrong tag */ public static SplitMintJustification fromCbor(byte[] bytes) { CborDeserializer.CborTag tag = CborDeserializer.decodeTag(bytes); if (tag.getTag() != SplitMintJustification.CBOR_TAG) { - throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); + throw new CborSerializationException( + String.format("Invalid CBOR tag for SplitMintJustification: %s", tag.getTag())); } + List data = CborDeserializer.decodeArray(tag.getData(), 2); + return SplitMintJustification.create( Token.fromCbor(data.get(0)), CborDeserializer.decodeArray(data.get(1)).stream() - .map(SplitAssetProof::fromCbor) + .map(SplitAllocationProof::fromCbor) .collect(Collectors.toList()) ); } /** - * Serialize split mint justification to CBOR bytes. + * Convert SplitMintJustification to CBOR bytes. * * @return CBOR bytes */ @@ -100,7 +151,10 @@ public byte[] toCbor() { SplitMintJustification.CBOR_TAG, CborSerializer.encodeArray( this.token.toCbor(), - CborSerializer.encodeArray(this.proofs.stream().map(SplitAssetProof::toCbor).toArray(byte[][]::new)) + CborSerializer.encodeArray( + this.proofs.stream() + .map(SplitAllocationProof::toCbor) + .toArray(byte[][]::new)) ) ); } diff --git a/src/main/java/org/unicitylabs/sdk/payment/SplitMintJustificationVerifier.java b/src/main/java/org/unicitylabs/sdk/payment/SplitMintJustificationVerifier.java index 864620fe..13a35d8e 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/SplitMintJustificationVerifier.java +++ b/src/main/java/org/unicitylabs/sdk/payment/SplitMintJustificationVerifier.java @@ -1,42 +1,40 @@ package org.unicitylabs.sdk.payment; -import org.unicitylabs.sdk.api.NetworkId; -import org.unicitylabs.sdk.api.bft.RootTrustBase; import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.DataHasher; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; import org.unicitylabs.sdk.payment.asset.Asset; import org.unicitylabs.sdk.payment.asset.AssetId; import org.unicitylabs.sdk.predicate.EncodedPredicate; import org.unicitylabs.sdk.predicate.builtin.BurnPredicate; -import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; -import org.unicitylabs.sdk.smt.MerkleTreePathVerificationResult; import org.unicitylabs.sdk.transaction.CertifiedMintTransaction; -import org.unicitylabs.sdk.transaction.Transaction; +import org.unicitylabs.sdk.transaction.CertifiedTransferTransaction; +import org.unicitylabs.sdk.transaction.Token; import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifier; -import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.util.verification.VerificationStatus; -import java.math.BigInteger; -import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; - +import java.util.function.Consumer; + +/** + * Verifier for {@link SplitMintJustification} mint justifications. It reports the burned source + * token for caller verification, binds the burn to the split manifest, recomputes the output's + * leaf data, and verifies every output asset against its allocation proof — requiring each + * asset's reconstructed total to equal the source amount (value conservation). + */ public class SplitMintJustificationVerifier implements MintJustificationVerifier { - private final RootTrustBase trustBase; - private final PredicateVerifierService predicateVerifier; + + private static final String RULE = "SplitMintJustificationVerifier"; + private final PaymentDataDeserializer decodePaymentData; - public SplitMintJustificationVerifier( - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - PaymentDataDeserializer decodePaymentData - ) { - this.trustBase = Objects.requireNonNull(trustBase, "trustBase cannot be null"); - this.predicateVerifier = Objects.requireNonNull(predicateVerifier, "predicateVerifier cannot be null"); - this.decodePaymentData = Objects.requireNonNull(decodePaymentData, "decodePaymentData cannot be null"); + public SplitMintJustificationVerifier(PaymentDataDeserializer decodePaymentData) { + this.decodePaymentData = Objects.requireNonNull(decodePaymentData, + "decodePaymentData cannot be null"); } @Override @@ -44,179 +42,120 @@ public long getTag() { return SplitMintJustification.CBOR_TAG; } + private static VerificationResult fail(String message) { + return new VerificationResult<>(SplitMintJustificationVerifier.RULE, VerificationStatus.FAIL, + message); + } + @Override - public VerificationResult verify(CertifiedMintTransaction transaction, MintJustificationVerifierService mintJustificationVerifier) { + public VerificationResult verify(CertifiedMintTransaction transaction, + Consumer nestedTokenCollector) { Objects.requireNonNull(transaction, "transaction cannot be null"); - Objects.requireNonNull(mintJustificationVerifier, "mintJustificationVerifierService cannot be null"); + Objects.requireNonNull(nestedTokenCollector, "nestedTokenCollector cannot be null"); byte[] justificationBytes = transaction.getJustification().orElse(null); if (justificationBytes == null) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - "Transaction has no justification." - ); + return SplitMintJustificationVerifier.fail("Transaction has no justification."); } SplitMintJustification justification = SplitMintJustification.fromCbor(justificationBytes); - byte[] paymentDataBytes = transaction.getData().orElse(null); - PaymentData paymentData = paymentDataBytes != null ? this.decodePaymentData.decode(paymentDataBytes) : null; - - if (paymentData == null || paymentData.getAssets() == null) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - "Assets data is missing." - ); - } - NetworkId sourceNetworkId = justification.getToken().getGenesis().getNetworkId(); - if (!transaction.getNetworkId().equals(sourceNetworkId)) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, + if (!transaction.getNetworkId().equals(justification.getToken().getGenesis().getNetworkId())) { + return SplitMintJustificationVerifier.fail( String.format( "Network identifier mismatch: mint is on %s, source token is on %s.", transaction.getNetworkId(), - sourceNetworkId + justification.getToken().getGenesis().getNetworkId() ) ); } - VerificationResult verificationResult = justification.getToken() - .verify(trustBase, predicateVerifier, mintJustificationVerifier); - if (verificationResult.getStatus() != VerificationStatus.OK) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - "Burn token verification failed.", - verificationResult - ); - } - - Map assets = new HashMap<>(); - for (Asset asset : paymentData.getAssets()) { - if (asset == null) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - "Asset data is missing." - ); - } + nestedTokenCollector.accept(justification.getToken()); - AssetId assetId = asset.getId(); - if (assets.putIfAbsent(assetId, asset) != null) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - String.format("Duplicate asset id %s found in asset data.", assetId) - ); - } + List transfers = justification.getToken().getTransactions(); + if (transfers.isEmpty()) { + return SplitMintJustificationVerifier.fail( + "Burned source token does not end in a certified transfer."); } + CertifiedTransferTransaction burnTransaction = transfers.get(transfers.size() - 1); - if (assets.size() != justification.getProofs().size()) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - "Total amount of assets differ in token and proofs." - ); + byte[] manifestBytes = burnTransaction.getData().orElse(null); + if (manifestBytes == null) { + return SplitMintJustificationVerifier.fail("Burn transfer has no manifest."); + } + List roots = SplitManifest.fromCbor(manifestBytes).getRoots(); + + DataHash burnReason = new DataHasher(HashAlgorithm.SHA256).update(manifestBytes).digest(); + EncodedPredicate expectedRecipient = EncodedPredicate.fromPredicate( + BurnPredicate.create(burnReason.getData())); + if (!expectedRecipient.equals(burnTransaction.getRecipient())) { + return SplitMintJustificationVerifier.fail( + "Burn transfer recipient does not match the manifest hash."); } - Set validatedAssets = new HashSet<>(); - Transaction burnTokenLastTransaction = justification.getToken().getLatestTransaction(); - DataHash root = justification.getProofs().get(0).getAggregationPath().getRootHash(); - for (SplitAssetProof proof : justification.getProofs()) { - if (!validatedAssets.add(proof.getAssetId())) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - String.format("Duplicate split proof for asset id %s.", proof.getAssetId()) - ); - } - - MerkleTreePathVerificationResult aggregationPathResult = proof.getAggregationPath() - .verify(proof.getAssetId().toBitString().toBigInteger()); - if (!aggregationPathResult.isSuccessful()) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - String.format("Aggregation path verification failed for asset: %s", proof.getAssetId()) - ); - } - - MerkleTreePathVerificationResult assetTreePathResult = proof.getAssetTreePath() - .verify(transaction.getTokenId().toBitString().toBigInteger()); - if (!assetTreePathResult.isSuccessful()) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - String.format("Asset tree path verification failed for token: %s", transaction.getTokenId()) - ); - } + if (!transaction.getTokenType().equals(justification.getToken().getType())) { + return SplitMintJustificationVerifier.fail( + "Output token type does not match the source token type."); + } - if (!proof.getAggregationPath().getRootHash().equals(root)) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - "Current proof is not derived from the same asset tree as other proofs." - ); - } + byte[] sourcePaymentBytes = justification.getToken().getGenesis().getData().orElse(null); + PaymentData sourceTokenPaymentData = sourcePaymentBytes != null + ? this.decodePaymentData.decode(sourcePaymentBytes) + : null; + if (sourceTokenPaymentData == null + || sourceTokenPaymentData.getAssets().size() != roots.size()) { + return SplitMintJustificationVerifier.fail( + "Manifest root count does not match the source asset count."); + } - if (!Arrays.equals( - proof.getAssetTreePath().getRootHash().getImprint(), - proof.getAggregationPath().getSteps().get(0).getData().orElse(null) - )) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - "Asset tree root does not match aggregation path leaf." - ); - } + byte[] paymentDataBytes = transaction.getData().orElse(null); + PaymentData paymentData = paymentDataBytes != null + ? this.decodePaymentData.decode(paymentDataBytes) + : null; + if (paymentData == null + || justification.getProofs().size() != paymentData.getAssets().size()) { + return SplitMintJustificationVerifier.fail( + "Allocation proof count does not match the output asset count."); + } - Asset asset = assets.get(proof.getAssetId()); + byte[] leafData = SplitMintJustification.calculateLeafData( + justification.getToken(), + transaction.getRecipient(), + transaction.getSalt(), + transaction.getTokenId(), + paymentDataBytes + ); - if (asset == null) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - String.format("Asset id %s not found in asset data.", proof.getAssetId()) - ); - } + List assets = paymentData.getAssets().toList(); - BigInteger amount = asset.getValue(); + Map rootByAsset = new LinkedHashMap<>(); + List sourceAssets = sourceTokenPaymentData.getAssets().toList(); + for (int i = 0; i < sourceAssets.size(); i++) { + rootByAsset.put(sourceAssets.get(i).getId(), roots.get(i)); + } - if (!proof.getAssetTreePath().getSteps().get(0).getValue().equals(amount)) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - String.format("Asset amount for asset id %s does not match asset tree leaf.", proof.getAssetId()) - ); + for (int i = 0; i < assets.size(); i++) { + Asset asset = assets.get(i); + Asset sourceAsset = sourceTokenPaymentData.getAssets().get(asset.getId()); + DataHash root = rootByAsset.get(asset.getId()); + if (sourceAsset == null || root == null) { + return SplitMintJustificationVerifier.fail( + String.format("Asset %s is absent from the source token.", asset.getId())); } - EncodedPredicate expectedRecipient = EncodedPredicate.fromPredicate( - BurnPredicate.create(proof.getAggregationPath().getRootHash().getImprint()) + boolean isProofValid = justification.getProofs().get(i).verify( + transaction.getTokenId().getBytes(), + leafData, + asset.getValue(), + root, + sourceAsset.getValue() ); - - if (!expectedRecipient.equals(burnTokenLastTransaction.getRecipient())) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - "Aggregation path root does not match burn predicate." - ); + if (!isProofValid) { + return SplitMintJustificationVerifier.fail( + String.format("Allocation proof failed for asset %s.", asset.getId())); } } - if (validatedAssets.size() != assets.size()) { - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.FAIL, - "Some assets proofs are missing from the token." - ); - } - - return new VerificationResult<>( - "SplitMintJustificationVerificationRule", - VerificationStatus.OK - ); + return new VerificationResult<>(SplitMintJustificationVerifier.RULE, VerificationStatus.OK); } } diff --git a/src/main/java/org/unicitylabs/sdk/payment/SplitResult.java b/src/main/java/org/unicitylabs/sdk/payment/SplitResult.java index 80981499..72419b5a 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/SplitResult.java +++ b/src/main/java/org/unicitylabs/sdk/payment/SplitResult.java @@ -1,22 +1,36 @@ package org.unicitylabs.sdk.payment; +import org.unicitylabs.sdk.predicate.builtin.BurnPredicate; import org.unicitylabs.sdk.transaction.TransferTransaction; import java.util.List; /** - * Result of token split generation containing burn transaction and per-output split tokens. + * Result of token split generation containing the burn owner predicate, the burn transaction and + * per-output split tokens. */ public class SplitResult { + private final BurnPredicate burnPredicate; private final TransferTransaction burnTransaction; private final List tokens; - SplitResult(TransferTransaction burnTransaction, List tokens) { + SplitResult(BurnPredicate burnPredicate, TransferTransaction burnTransaction, + List tokens) { + this.burnPredicate = burnPredicate; this.burnTransaction = burnTransaction; this.tokens = List.copyOf(tokens); } + /** + * Get the burn owner predicate committing to the split manifest hash. + * + * @return burn predicate + */ + public BurnPredicate getBurnPredicate() { + return this.burnPredicate; + } + /** * Get the burn transaction that anchors split proofs. * @@ -34,4 +48,4 @@ public TransferTransaction getBurnTransaction() { public List getTokens() { return this.tokens; } -} \ No newline at end of file +} diff --git a/src/main/java/org/unicitylabs/sdk/payment/SplitToken.java b/src/main/java/org/unicitylabs/sdk/payment/SplitToken.java index eb55d6c5..7db913e4 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/SplitToken.java +++ b/src/main/java/org/unicitylabs/sdk/payment/SplitToken.java @@ -1,16 +1,16 @@ package org.unicitylabs.sdk.payment; import org.unicitylabs.sdk.api.NetworkId; -import org.unicitylabs.sdk.payment.asset.Asset; import org.unicitylabs.sdk.predicate.Predicate; import org.unicitylabs.sdk.transaction.TokenSalt; import org.unicitylabs.sdk.transaction.TokenType; import java.util.List; -import java.util.Set; /** - * Realized split output: all data needed to mint the new token. + * Realized split output: everything needed to mint the new token. Mint it with exactly + * {@code getPaymentData().encode()} as the auxiliary payload - those are the bytes bound by the + * split allocation proofs. */ public class SplitToken { @@ -18,22 +18,22 @@ public class SplitToken { private final Predicate recipient; private final TokenType tokenType; private final TokenSalt salt; - private final Set assets; - private final List proofs; + private final PaymentData paymentData; + private final List proofs; SplitToken( NetworkId networkId, Predicate recipient, TokenType tokenType, TokenSalt salt, - Set assets, - List proofs + PaymentData paymentData, + List proofs ) { this.networkId = networkId; this.recipient = recipient; this.tokenType = tokenType; this.salt = salt; - this.assets = Set.copyOf(assets); + this.paymentData = paymentData; this.proofs = List.copyOf(proofs); } @@ -53,11 +53,11 @@ public TokenSalt getSalt() { return this.salt; } - public Set getAssets() { - return this.assets; + public PaymentData getPaymentData() { + return this.paymentData; } - public List getProofs() { + public List getProofs() { return this.proofs; } -} \ No newline at end of file +} diff --git a/src/main/java/org/unicitylabs/sdk/payment/SplitTokenRequest.java b/src/main/java/org/unicitylabs/sdk/payment/SplitTokenRequest.java index ca5199ad..d3e8840f 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/SplitTokenRequest.java +++ b/src/main/java/org/unicitylabs/sdk/payment/SplitTokenRequest.java @@ -1,92 +1,66 @@ package org.unicitylabs.sdk.payment; -import org.unicitylabs.sdk.payment.asset.Asset; import org.unicitylabs.sdk.predicate.Predicate; import org.unicitylabs.sdk.transaction.TokenSalt; -import org.unicitylabs.sdk.transaction.TokenType; import java.util.Objects; -import java.util.Set; /** - * Request to mint one new token as part of a token split. + * Request to mint one new token as part of a token split. Splitting preserves the source token + * type, so the output token type is not chosen here. The payment data carries both the output's + * assets and its self-encoding, so each output may embed its own token-type-specific payload + * alongside the asset allocation. */ public class SplitTokenRequest { private final Predicate recipient; - private final TokenType tokenType; - private final Set assets; + private final PaymentData paymentData; private final TokenSalt salt; - private SplitTokenRequest(Predicate recipient, TokenType tokenType, Set assets, TokenSalt salt) { + private SplitTokenRequest(Predicate recipient, PaymentData paymentData, TokenSalt salt) { this.recipient = recipient; - this.tokenType = tokenType; - this.assets = Set.copyOf(assets); + this.paymentData = paymentData; this.salt = salt; } /** - * Create a split token request. + * Create a SplitTokenRequest. * * @param recipient predicate that will lock the new token - * @param assets assets the new token will receive - * @param tokenType token type for the new token + * @param paymentData payment data the new token will carry; its assets are allocated from the + * source and its {@code encode()} produces the exact minted payload * @param salt salt for the new token - * - * @return split token request + * @return new request */ - public static SplitTokenRequest create( - Predicate recipient, - Set assets, - TokenType tokenType, - TokenSalt salt - ) { + public static SplitTokenRequest create(Predicate recipient, PaymentData paymentData, + TokenSalt salt) { Objects.requireNonNull(recipient, "Recipient cannot be null"); - Objects.requireNonNull(assets, "Assets cannot be null"); - Objects.requireNonNull(tokenType, "Token type cannot be null"); + Objects.requireNonNull(paymentData, "Payment data cannot be null"); Objects.requireNonNull(salt, "Salt cannot be null"); - return new SplitTokenRequest(recipient, tokenType, assets, salt); + return new SplitTokenRequest(recipient, paymentData, salt); } /** - * Create a split token request with a random salt. + * Create a SplitTokenRequest with a random salt. * * @param recipient predicate that will lock the new token - * @param assets assets the new token will receive - * @param tokenType token type for the new token - * - * @return split token request + * @param paymentData payment data the new token will carry + * @return new request */ - public static SplitTokenRequest create(Predicate recipient, Set assets, TokenType tokenType) { - return SplitTokenRequest.create(recipient, assets, tokenType, TokenSalt.generate()); - } - - /** - * Create a split token request with a random token type and salt. - * - * @param recipient predicate that will lock the new token - * @param assets assets the new token will receive - * - * @return split token request - */ - public static SplitTokenRequest create(Predicate recipient, Set assets) { - return SplitTokenRequest.create(recipient, assets, TokenType.generate(), TokenSalt.generate()); + public static SplitTokenRequest create(Predicate recipient, PaymentData paymentData) { + return SplitTokenRequest.create(recipient, paymentData, TokenSalt.generate()); } public Predicate getRecipient() { return this.recipient; } - public TokenType getTokenType() { - return this.tokenType; - } - - public Set getAssets() { - return this.assets; + public PaymentData getPaymentData() { + return this.paymentData; } public TokenSalt getSalt() { return this.salt; } -} \ No newline at end of file +} diff --git a/src/main/java/org/unicitylabs/sdk/payment/TokenAssetCountMismatchException.java b/src/main/java/org/unicitylabs/sdk/payment/TokenAssetCountMismatchException.java new file mode 100644 index 00000000..77a6749c --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/payment/TokenAssetCountMismatchException.java @@ -0,0 +1,14 @@ +package org.unicitylabs.sdk.payment; + +/** + * Thrown when the split requests do not cover exactly the source token's assets. + */ +public class TokenAssetCountMismatchException extends IllegalArgumentException { + + /** + * Create the exception. + */ + public TokenAssetCountMismatchException() { + super("Token and split tokens asset counts differ."); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/payment/TokenAssetMissingException.java b/src/main/java/org/unicitylabs/sdk/payment/TokenAssetMissingException.java new file mode 100644 index 00000000..a920b7db --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/payment/TokenAssetMissingException.java @@ -0,0 +1,18 @@ +package org.unicitylabs.sdk.payment; + +import org.unicitylabs.sdk.payment.asset.AssetId; + +/** + * Thrown when a split request references an asset the source token does not contain. + */ +public class TokenAssetMissingException extends IllegalArgumentException { + + /** + * Create the exception. + * + * @param assetId missing asset id + */ + public TokenAssetMissingException(AssetId assetId) { + super(String.format("Token did not contain asset %s.", assetId)); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/payment/TokenAssetValueMismatchException.java b/src/main/java/org/unicitylabs/sdk/payment/TokenAssetValueMismatchException.java new file mode 100644 index 00000000..b20e86ce --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/payment/TokenAssetValueMismatchException.java @@ -0,0 +1,24 @@ +package org.unicitylabs.sdk.payment; + +import org.unicitylabs.sdk.payment.asset.AssetId; + +import java.math.BigInteger; + +/** + * Thrown when the split outputs for an asset do not sum to the source token's asset value. + */ +public class TokenAssetValueMismatchException extends IllegalArgumentException { + + /** + * Create the exception. + * + * @param assetId asset id + * @param value source token asset value + * @param splitValue total value committed by the split outputs + */ + public TokenAssetValueMismatchException(AssetId assetId, BigInteger value, + BigInteger splitValue) { + super(String.format("Token contained %s %s assets, but tree has %s", value, assetId, + splitValue)); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java b/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java index 19bb74f8..e2b185f0 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java +++ b/src/main/java/org/unicitylabs/sdk/payment/TokenSplit.java @@ -1,71 +1,90 @@ package org.unicitylabs.sdk.payment; import org.unicitylabs.sdk.api.NetworkId; +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.DataHasher; import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; import org.unicitylabs.sdk.payment.asset.Asset; import org.unicitylabs.sdk.payment.asset.AssetId; +import org.unicitylabs.sdk.payment.asset.PaymentAssetCollection; +import org.unicitylabs.sdk.predicate.EncodedPredicate; import org.unicitylabs.sdk.predicate.builtin.BurnPredicate; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.smt.BranchExistsException; -import org.unicitylabs.sdk.smt.LeafOutOfBoundsException; -import org.unicitylabs.sdk.smt.plain.SparseMerkleTree; -import org.unicitylabs.sdk.smt.plain.SparseMerkleTreeRootNode; -import org.unicitylabs.sdk.smt.sum.SparseMerkleSumTree; -import org.unicitylabs.sdk.smt.sum.SparseMerkleSumTreeRootNode; +import org.unicitylabs.sdk.smt.LeafExistsException; +import org.unicitylabs.sdk.smt.radixsum.SparseMerkleSumTree; +import org.unicitylabs.sdk.smt.radixsum.SparseMerkleSumTreeRootNode; +import org.unicitylabs.sdk.transaction.StateMask; import org.unicitylabs.sdk.transaction.Token; import org.unicitylabs.sdk.transaction.TokenId; import org.unicitylabs.sdk.transaction.TransferTransaction; -import java.math.BigInteger; -import java.security.SecureRandom; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Map.Entry; import java.util.Objects; -import java.util.Set; import java.util.stream.Collectors; - /** - * Utilities for creating and verifying token split proofs. + * Token splitting. Burns the source token and prepares value-conserving output mints, building + * one radix sparse Merkle sum tree per source asset so that, for each asset, the outputs provably + * sum to the source amount. */ public class TokenSplit { - private static final SecureRandom RANDOM = new SecureRandom(); - private TokenSplit() { } /** - * Create split proofs and burn transaction for the provided split token requests. - * - * @param token source token being split - * @param paymentDataDeserializer payment data decoder for source token payload - * @param requests per-output mint requests + * Split a token into new outputs with a random burn state mask. * - * @return split result containing burn transaction and split tokens - * - * @throws LeafOutOfBoundsException if a leaf path is invalid for merkle tree insertion - * @throws BranchExistsException if duplicate branches are inserted into a merkle tree + * @param token source token to split (the token being burned) + * @param paymentDataDeserializer decoder for the source token's payment data + * @param requests per-output mint requests; each carries its own payment data + * @return burn predicate, burn transaction and split tokens ready to mint + * @throws LeafExistsException if duplicate leaves are inserted into a merkle tree */ public static SplitResult split( Token token, PaymentDataDeserializer paymentDataDeserializer, List requests - ) throws LeafOutOfBoundsException, BranchExistsException { + ) throws LeafExistsException { + return TokenSplit.split(token, paymentDataDeserializer, requests, StateMask.generate()); + } + + /** + * Split a token into new outputs. + * + * @param token source token to split (the token being burned) + * @param paymentDataDeserializer decoder for the source token's payment data + * @param requests per-output mint requests; each carries its own payment data + * @param burnStateMask state mask for the burn transaction; callers needing a crash-resumable + * (re-buildable) split supply a deterministically derived mask so the identical burn + * transaction can be reconstructed after a failure + * @return burn predicate, burn transaction and split tokens ready to mint + * @throws LeafExistsException if duplicate leaves are inserted into a merkle tree + */ + public static SplitResult split( + Token token, + PaymentDataDeserializer paymentDataDeserializer, + List requests, + StateMask burnStateMask + ) throws LeafExistsException { Objects.requireNonNull(token, "Token cannot be null"); Objects.requireNonNull(paymentDataDeserializer, "Payment data deserializer cannot be null"); Objects.requireNonNull(requests, "Requests cannot be null"); + Objects.requireNonNull(burnStateMask, "Burn state mask cannot be null"); + byte[] paymentDataBytes = token.getGenesis().getData().orElse(null); if (paymentDataBytes == null) { - throw new IllegalArgumentException("Token genesis data must be present"); + throw new IllegalArgumentException("Payment data is missing."); } + PaymentAssetCollection sourceAssets = + paymentDataDeserializer.decode(paymentDataBytes).getAssets(); + NetworkId networkId = token.getGenesis().getNetworkId(); - HashMap trees = new HashMap<>(); + Map trees = new HashMap<>(); LinkedHashMap requestsByTokenId = new LinkedHashMap<>(); for (SplitTokenRequest request : requests) { Objects.requireNonNull(request, "Split token request cannot be null"); @@ -75,93 +94,74 @@ public static SplitResult split( } requestsByTokenId.put(tokenId, request); - BigInteger tokenIdPath = tokenId.toBitString().toBigInteger(); - for (Asset asset : request.getAssets()) { - Objects.requireNonNull(asset, "Split token asset cannot be null"); + EncodedPredicate recipient = EncodedPredicate.fromPredicate(request.getRecipient()); + byte[] data = request.getPaymentData().encode(); + byte[] leafData = SplitMintJustification.calculateLeafData( + token, recipient, request.getSalt(), tokenId, data); + + for (Asset asset : request.getPaymentData().getAssets().toList()) { + if (sourceAssets.get(asset.getId()) == null) { + throw new TokenAssetMissingException(asset.getId()); + } + SparseMerkleSumTree tree = trees.computeIfAbsent(asset.getId(), - v -> new SparseMerkleSumTree(HashAlgorithm.SHA256)); - tree.addLeaf( - tokenIdPath, - new SparseMerkleSumTree.LeafValue(asset.getId().getBytes(), asset.getValue()) - ); + id -> new SparseMerkleSumTree(HashAlgorithm.SHA256)); + tree.addLeaf(tokenId.getBytes(), leafData, asset.getValue()); } } - PaymentData paymentData = paymentDataDeserializer.decode(paymentDataBytes); - Map assets = paymentData.getAssets().stream() - .collect(Collectors.toMap( - Asset::getId, - asset -> asset, - (a, b) -> { - throw new IllegalArgumentException( - "Payment data contains multiple assets with the same id: " + a.getId()); - } - ) - ); - - if (trees.size() != assets.size()) { - throw new IllegalArgumentException("Token and split tokens asset counts differ."); + if (trees.size() != sourceAssets.size()) { + throw new TokenAssetCountMismatchException(); } - SparseMerkleTree aggregationTree = new SparseMerkleTree(HashAlgorithm.SHA256); - HashMap assetTreeRoots = new HashMap<>(); - for (Entry entry : trees.entrySet()) { - Asset tokenAsset = assets.get(entry.getKey()); - if (tokenAsset == null) { - throw new IllegalArgumentException(String.format("Token did not contain asset %s.", entry.getKey())); + List roots = new ArrayList<>(); + Map rootByAsset = new HashMap<>(); + for (Asset asset : sourceAssets.toList()) { + SparseMerkleSumTree tree = trees.get(asset.getId()); + if (tree == null) { + throw new TokenAssetMissingException(asset.getId()); } - SparseMerkleSumTreeRootNode root = entry.getValue().calculateRoot(); - if (!root.getValue().equals(tokenAsset.getValue())) { - throw new IllegalArgumentException( - String.format( - "Token contained %s %s assets, but tree has %s", - tokenAsset.getValue(), - tokenAsset.getId(), - root.getValue() - ) - ); + SparseMerkleSumTreeRootNode root = tree.calculateRoot(); + if (!root.getValue().equals(asset.getValue())) { + throw new TokenAssetValueMismatchException(asset.getId(), asset.getValue(), + root.getValue()); } - assetTreeRoots.put(tokenAsset.getId(), root); - aggregationTree.addLeaf(tokenAsset.getId().toBitString().toBigInteger(), root.getRootHash().getImprint()); + roots.add(root.getHash()); + rootByAsset.put(asset.getId(), root); } - SparseMerkleTreeRootNode aggregationRoot = aggregationTree.calculateRoot(); - BurnPredicate burnPredicate = BurnPredicate.create(aggregationRoot.getRootHash().getImprint()); - byte[] stateMask = new byte[32]; - RANDOM.nextBytes(stateMask); - + byte[] manifestBytes = SplitManifest.create(roots).toCbor(); + byte[] burnReason = new DataHasher(HashAlgorithm.SHA256).update(manifestBytes).digest() + .getData(); + BurnPredicate burnPredicate = BurnPredicate.create(burnReason); TransferTransaction burnTransaction = TransferTransaction.create( token, burnPredicate, - stateMask, - CborSerializer.encodeNull() + burnStateMask, + manifestBytes ); List tokens = new ArrayList<>(requestsByTokenId.size()); - for (Entry entry : requestsByTokenId.entrySet()) { + for (Map.Entry entry : requestsByTokenId.entrySet()) { SplitTokenRequest request = entry.getValue(); - BigInteger tokenIdPath = entry.getKey().toBitString().toBigInteger(); - Set requestAssets = request.getAssets(); - List proofs = requestAssets.stream() - .map(asset -> SplitAssetProof.create( - asset.getId(), - aggregationRoot.getPath(asset.getId().toBitString().toBigInteger()), - assetTreeRoots.get(asset.getId()).getPath(tokenIdPath) - )) + TokenId tokenId = entry.getKey(); + List proofs = request.getPaymentData().getAssets().toList().stream() + .map(asset -> SplitAllocationProof.create( + rootByAsset.get(asset.getId()), tokenId.getBytes())) .collect(Collectors.toList()); + tokens.add(new SplitToken( networkId, request.getRecipient(), - request.getTokenType(), + token.getType(), request.getSalt(), - requestAssets, + request.getPaymentData(), proofs )); } - return new SplitResult(burnTransaction, tokens); + return new SplitResult(burnPredicate, burnTransaction, tokens); } - -} \ No newline at end of file +} diff --git a/src/main/java/org/unicitylabs/sdk/payment/asset/Asset.java b/src/main/java/org/unicitylabs/sdk/payment/asset/Asset.java index ee253dd9..a4a79d51 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/asset/Asset.java +++ b/src/main/java/org/unicitylabs/sdk/payment/asset/Asset.java @@ -2,7 +2,6 @@ import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.util.BigIntegerConverter; import java.math.BigInteger; import java.util.List; @@ -13,6 +12,8 @@ */ public final class Asset { + private static final BigInteger VALUE_LIMIT = BigInteger.ONE.shiftLeft(256); + private final BigInteger value; private final AssetId id; @@ -20,14 +21,14 @@ public final class Asset { * Create a new asset with the given ID and value. * * @param id asset ID - * @param value asset value + * @param value asset value in the range {@code [1, 2^256)} */ public Asset(AssetId id, BigInteger value) { this.id = Objects.requireNonNull(id, "Asset ID cannot be null"); this.value = Objects.requireNonNull(value, "Asset value cannot be null"); - if (this.value.compareTo(BigInteger.ZERO) < 0) { - throw new IllegalArgumentException("Asset value cannot be negative"); + if (this.value.signum() <= 0 || this.value.compareTo(Asset.VALUE_LIMIT) >= 0) { + throw new IllegalArgumentException("Asset value must be a positive 256-bit integer."); } } @@ -60,7 +61,7 @@ public static Asset fromCbor(byte[] bytes) { return new Asset( AssetId.fromCbor(data.get(0)), - BigIntegerConverter.decode(CborDeserializer.decodeByteString(data.get(1))) + CborDeserializer.decodeBigInteger(data.get(1), 32) ); } @@ -72,7 +73,7 @@ public static Asset fromCbor(byte[] bytes) { public byte[] toCbor() { return CborSerializer.encodeArray( this.id.toCbor(), - CborSerializer.encodeByteString(BigIntegerConverter.encode(this.value)) + CborSerializer.encodeBigInteger(this.value) ); } diff --git a/src/main/java/org/unicitylabs/sdk/payment/asset/AssetId.java b/src/main/java/org/unicitylabs/sdk/payment/asset/AssetId.java index 0d8ad4d9..4e3ed5ef 100644 --- a/src/main/java/org/unicitylabs/sdk/payment/asset/AssetId.java +++ b/src/main/java/org/unicitylabs/sdk/payment/asset/AssetId.java @@ -12,16 +12,26 @@ * Unique identifier of an asset. */ public class AssetId { + public static final int MIN_LENGTH = 1; + public static final int MAX_LENGTH = 128; + private final byte[] bytes; /** * Create asset id from bytes. * - * @param bytes asset id bytes + * @param bytes asset id bytes; must be between {@link #MIN_LENGTH} and {@link #MAX_LENGTH} + * bytes long */ public AssetId(byte[] bytes) { Objects.requireNonNull(bytes, "Asset id cannot be null"); + if (bytes.length < AssetId.MIN_LENGTH || bytes.length > AssetId.MAX_LENGTH) { + throw new IllegalArgumentException( + String.format("AssetId must be between %d and %d bytes long, got %d.", + AssetId.MIN_LENGTH, AssetId.MAX_LENGTH, bytes.length)); + } + this.bytes = Arrays.copyOf(bytes, bytes.length); } diff --git a/src/main/java/org/unicitylabs/sdk/payment/asset/PaymentAssetCollection.java b/src/main/java/org/unicitylabs/sdk/payment/asset/PaymentAssetCollection.java new file mode 100644 index 00000000..b600a6bf --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/payment/asset/PaymentAssetCollection.java @@ -0,0 +1,141 @@ +package org.unicitylabs.sdk.payment.asset; + +import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; +import org.unicitylabs.sdk.serializer.cbor.CborSerializer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Asset-id-keyed collection of {@link Asset} values used in a payment. Assets are held in + * canonical asset-id order — the order the split protocol requires — so {@link #toList()} and + * {@link #toCbor()} always produce that order. + */ +public final class PaymentAssetCollection { + + public static final int MIN_SIZE = 1; + public static final int MAX_SIZE = 256; + + private final Map assets; + + private PaymentAssetCollection(Map assets) { + this.assets = assets; + } + + /** + * Create a PaymentAssetCollection. Assets may be supplied in any order; they are stored + * canonically. + * + * @param assets assets to include (1..256, distinct ids) + * @return new collection + * @throws IllegalArgumentException if the count is out of range, or an asset id repeats + */ + public static PaymentAssetCollection create(Asset... assets) { + List sorted = new ArrayList<>(Arrays.asList(assets)); + sorted.sort(PaymentAssetCollection::compareAssets); + return PaymentAssetCollection.fromList(sorted); + } + + /** + * Create PaymentAssetCollection from CBOR bytes. The encoded assets MUST be in strict canonical + * asset-id order with no duplicates. + * + * @param bytes CBOR bytes + * @return decoded collection + * @throws CborSerializationException if the assets are not in strict canonical asset-id order + */ + public static PaymentAssetCollection fromCbor(byte[] bytes) { + List assets = CborDeserializer.decodeArray(bytes).stream() + .map(Asset::fromCbor) + .collect(Collectors.toList()); + for (int i = 1; i < assets.size(); i++) { + if (PaymentAssetCollection.compareAssets(assets.get(i - 1), assets.get(i)) >= 0) { + throw new CborSerializationException( + "Payment assets must be in strict canonical asset-id order."); + } + } + + return PaymentAssetCollection.fromList(assets); + } + + /** + * Compare two assets by their asset id in canonical order: ascending unsigned lexicographic + * order of the raw id bytes, a shorter id ordered before a longer one that it is a prefix of. + */ + private static int compareAssets(Asset a, Asset b) { + byte[] x = a.getId().getBytes(); + byte[] y = b.getId().getBytes(); + int length = Math.min(x.length, y.length); + for (int i = 0; i < length; i++) { + int diff = (x[i] & 0xFF) - (y[i] & 0xFF); + if (diff != 0) { + return diff; + } + } + + return x.length - y.length; + } + + private static PaymentAssetCollection fromList(List assets) { + if (assets.size() < PaymentAssetCollection.MIN_SIZE + || assets.size() > PaymentAssetCollection.MAX_SIZE) { + throw new IllegalArgumentException( + String.format("Payment asset collection must hold between %d and %d assets, got %d.", + PaymentAssetCollection.MIN_SIZE, PaymentAssetCollection.MAX_SIZE, + assets.size())); + } + + Map map = new LinkedHashMap<>(); + for (Asset asset : assets) { + if (map.putIfAbsent(asset.getId(), asset) != null) { + throw new IllegalArgumentException( + "Invalid payment asset collection. Duplicate assets found."); + } + } + + return new PaymentAssetCollection(map); + } + + /** + * Look up the asset with the given id. + * + * @param id asset id + * @return matching asset, or {@code null} + */ + public Asset get(AssetId id) { + return this.assets.get(id); + } + + /** + * Get the number of assets in this collection. + * + * @return asset count + */ + public int size() { + return this.assets.size(); + } + + /** + * Get the assets in canonical asset-id order. + * + * @return assets + */ + public List toList() { + return List.copyOf(this.assets.values()); + } + + /** + * Serialize this collection to CBOR bytes (assets in canonical order). + * + * @return CBOR bytes + */ + public byte[] toCbor() { + return CborSerializer.encodeArray( + this.assets.values().stream().map(Asset::toCbor).toArray(byte[][]::new)); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/predicate/builtin/BuiltInPredicateType.java b/src/main/java/org/unicitylabs/sdk/predicate/builtin/BuiltInPredicateType.java index ffb0222a..13d9125f 100644 --- a/src/main/java/org/unicitylabs/sdk/predicate/builtin/BuiltInPredicateType.java +++ b/src/main/java/org/unicitylabs/sdk/predicate/builtin/BuiltInPredicateType.java @@ -7,9 +7,7 @@ public enum BuiltInPredicateType { /** Predicate that locks state to a public key. */ SIGNATURE(0x01), /** Predicate that marks state as unspendable (burned). */ - BURN(0x02), - /** Predicate that references a Unicity identifier. */ - UNICITY_ID(0x100); + BURN(0x02); private final int id; diff --git a/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifier.java b/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifier.java index f3c0c543..5848c6e5 100644 --- a/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifier.java +++ b/src/main/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifier.java @@ -3,11 +3,11 @@ import org.unicitylabs.sdk.crypto.hash.DataHash; import org.unicitylabs.sdk.crypto.hash.DataHasher; import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.crypto.secp256k1.Signature; import org.unicitylabs.sdk.crypto.secp256k1.SigningService; import org.unicitylabs.sdk.predicate.EncodedPredicate; import org.unicitylabs.sdk.predicate.builtin.BuiltInPredicateType; import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicateUnlockScript; import org.unicitylabs.sdk.serializer.cbor.CborSerializer; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.util.verification.VerificationStatus; @@ -32,8 +32,11 @@ public BuiltInPredicateType getType() { @Override public VerificationResult verify(EncodedPredicate encodedPredicate, DataHash sourceStateHash, - DataHash transactionHash, byte[] unlockScript) { + DataHash transactionHash, + byte[] unlockScriptBytes) { SignaturePredicate predicate = SignaturePredicate.fromPredicate(encodedPredicate); + SignaturePredicateUnlockScript unlockScript = + SignaturePredicateUnlockScript.decode(unlockScriptBytes); boolean result = SigningService.verifyWithPublicKey( new DataHasher(HashAlgorithm.SHA256) @@ -44,7 +47,7 @@ public VerificationResult verify(EncodedPredicate encodedPre ) ) .digest(), - Signature.decode(unlockScript).getBytes(), + unlockScript.getSignature(), predicate.getPublicKey() ); diff --git a/src/main/java/org/unicitylabs/sdk/serializer/cbor/CborDeserializer.java b/src/main/java/org/unicitylabs/sdk/serializer/cbor/CborDeserializer.java index 1d38d5ac..6f6ed376 100644 --- a/src/main/java/org/unicitylabs/sdk/serializer/cbor/CborDeserializer.java +++ b/src/main/java/org/unicitylabs/sdk/serializer/cbor/CborDeserializer.java @@ -2,7 +2,9 @@ import org.unicitylabs.sdk.serializer.cbor.CborSerializer.CborMap; import org.unicitylabs.sdk.serializer.cbor.CborSerializer.CborMap.Entry; +import org.unicitylabs.sdk.util.BigIntegerConverter; +import java.math.BigInteger; import java.util.*; import java.util.function.Function; @@ -43,12 +45,12 @@ public static T decodeNullable(byte[] data, Function decoder) { * @param data bytes * @return unsigned number */ - public static CborNumber decodeUnsignedInteger(byte[] data) { + public static CborUnsignedLong decodeUnsignedInteger(byte[] data) { CborReader reader = new CborReader(data); - long value = reader.readLength(CborMajorType.UNSIGNED_INTEGER); + CborUnsignedLong value = reader.readLength(CborMajorType.UNSIGNED_INTEGER); reader.assertExhausted(); - return new CborNumber(value); + return value; } /** @@ -59,12 +61,45 @@ public static CborNumber decodeUnsignedInteger(byte[] data) { */ public static byte[] decodeByteString(byte[] data) { CborReader reader = new CborReader(data); - byte[] result = reader.read((int) reader.readLength(CborMajorType.BYTE_STRING)); + byte[] result = reader.read(reader.readLength(CborMajorType.BYTE_STRING).asListSize()); reader.assertExhausted(); return result; } + /** + * Read a minimally encoded big-endian byte string from CBOR bytes as a non-negative big + * integer. Rejects non-minimal encodings (leading zero byte); zero is encoded as an empty + * byte string. + * + * @param data bytes + * @return decoded integer + */ + public static BigInteger decodeBigInteger(byte[] data) { + return CborDeserializer.decodeBigInteger(data, null); + } + + /** + * Read a minimally encoded big-endian byte string from CBOR bytes as a non-negative big + * integer, bounded to at most {@code maxByteLength} bytes. + * + * @param data bytes + * @param maxByteLength maximum byte-string length, or {@code null} for no limit + * @return decoded integer + */ + public static BigInteger decodeBigInteger(byte[] data, Integer maxByteLength) { + byte[] bytes = CborDeserializer.decodeByteString(data); + if (bytes.length > 0 && bytes[0] == 0) { + throw new CborSerializationException("Integer byte string must be minimally encoded."); + } + if (maxByteLength != null && bytes.length > maxByteLength) { + throw new CborSerializationException( + String.format("Integer byte string must be at most %d bytes.", maxByteLength)); + } + + return BigIntegerConverter.decode(bytes); + } + /** * Read text string from CBOR bytes. * @@ -73,7 +108,7 @@ public static byte[] decodeByteString(byte[] data) { */ public static String decodeTextString(byte[] data) { CborReader reader = new CborReader(data); - byte[] bytes = reader.read((int) reader.readLength(CborMajorType.TEXT_STRING)); + byte[] bytes = reader.read(reader.readLength(CborMajorType.TEXT_STRING).asListSize()); reader.assertExhausted(); return new String(bytes); @@ -87,7 +122,7 @@ public static String decodeTextString(byte[] data) { */ public static List decodeArray(byte[] data) { CborReader reader = new CborReader(data); - long length = reader.readLength(CborMajorType.ARRAY); + int length = reader.readLength(CborMajorType.ARRAY).asListSize(); ArrayList result = new ArrayList<>(); for (int i = 0; i < length; i++) { @@ -125,7 +160,7 @@ public static List decodeArray(byte[] data, long expectedLength) { */ public static Set decodeMap(byte[] data) { CborReader reader = new CborReader(data); - long length = (int) reader.readLength(CborMajorType.MAP); + int length = reader.readLength(CborMajorType.MAP).asListSize(); Set result = new LinkedHashSet<>(); Entry previous = null; @@ -157,7 +192,7 @@ public static Set decodeMap(byte[] data) { */ public static CborTag decodeTag(byte[] data) { CborReader reader = new CborReader(data); - long tag = (int) reader.readLength(CborMajorType.TAG); + long tag = reader.readLength(CborMajorType.TAG).asLong(); byte[] inner = reader.readRawCbor(); reader.assertExhausted(); @@ -212,18 +247,18 @@ public byte readByte() { } public byte[] read(int length) { - try { - if ((this.position + length) > this.data.length) { - throw new CborSerializationException("Premature end of data."); - } + if (length > this.data.length - this.position) { + throw new CborSerializationException("Premature end of data."); + } + try { return Arrays.copyOfRange(this.data, this.position, this.position + length); } finally { this.position += length; } } - public long readLength(CborMajorType majorType) { + public CborUnsignedLong readLength(CborMajorType majorType) { byte initialByte = this.readByte(); CborMajorType parsedMajorType = CborMajorType.fromType( @@ -236,7 +271,7 @@ public long readLength(CborMajorType majorType) { byte additionalInformation = (byte) (initialByte & CborDeserializer.ADDITIONAL_INFORMATION_MASK); if (Byte.compareUnsigned(additionalInformation, (byte) 24) < 0) { - return additionalInformation; + return new CborUnsignedLong(additionalInformation); } switch (majorType) { @@ -272,39 +307,42 @@ public long readLength(CborMajorType majorType) { length, Long.toUnsignedString(t))); } - return t; + return new CborUnsignedLong(t); } public byte[] readRawCbor() { - if (this.position >= this.data.length) { - throw new CborSerializationException("Premature end of data."); - } - - CborMajorType majorType = CborMajorType.fromType( - this.data[this.position] & CborDeserializer.MAJOR_TYPE_MASK); int position = this.position; - int length = (int) this.readLength(majorType); - switch (majorType) { - case BYTE_STRING: - case TEXT_STRING: - this.read(length); - break; - case ARRAY: - for (int i = 0; i < length; i++) { - this.readRawCbor(); - } - break; - case MAP: - for (int i = 0; i < length; i++) { - this.readRawCbor(); - this.readRawCbor(); - } - break; - case TAG: - this.readRawCbor(); - break; - default: - break; + + long remaining = 1; + while (remaining > 0) { + remaining--; + + if (this.position >= this.data.length) { + throw new CborSerializationException("Premature end of data."); + } + + CborMajorType majorType = CborMajorType.fromType( + this.data[this.position] & CborDeserializer.MAJOR_TYPE_MASK); + CborUnsignedLong length = this.readLength(majorType); + switch (majorType) { + case BYTE_STRING: + case TEXT_STRING: + this.read(length.asListSize()); + break; + case ARRAY: + // asInt bounds each count and every header consumes input, so the counter cannot + // overflow; undersupplied counts fail with premature end of data as items are read. + remaining += length.asListSize(); + break; + case MAP: + remaining += length.asListSize() * 2L; + break; + case TAG: + remaining += 1; + break; + default: + break; + } } return Arrays.copyOfRange(this.data, position, this.position); @@ -346,59 +384,82 @@ public byte[] getData() { /** * CBOR number implementation. */ - public static class CborNumber { + public static class CborUnsignedLong { private final long value; - private CborNumber(long value) { + private CborUnsignedLong(long value) { this.value = value; } /** - * Get number as long. + * Get the raw unsigned value as a {@code long}. Values at or above {@code 2^63} are returned + * as a negative long with the same bit pattern; use {@link Long#compareUnsigned} to compare + * them. * - * @return number + * @return value */ public long asLong() { return this.value; } /** - * Get number as int, throw error if does not fit. + * Get the value as an unsigned 32-bit integer, throwing if it exceeds {@code 0xFFFFFFFF}. + * Values above {@link Integer#MAX_VALUE} are returned with the same bit pattern (a negative + * {@code int}); mask with {@code & 0xFFFFFFFFL} to recover the unsigned value. * - * @return number + * @return value */ public int asInt() { if (Long.compareUnsigned(this.value, 0xFFFFFFFFL) > 0) { - throw new ArithmeticException("Value too large"); + throw new CborSerializationException("Value too large"); } return (int) this.value; } /** - * Get number as byte, throw error if does not fit. + * Get the value as an unsigned 16-bit integer, throwing if it exceeds {@code 0xFFFF}. Values + * above {@link Short#MAX_VALUE} are returned with the same bit pattern (a negative + * {@code short}); mask with {@code & 0xFFFF} to recover the unsigned value. * - * @return number + * @return value + */ + public short asShort() { + if (Long.compareUnsigned(this.value, 0xFFFFL) > 0) { + throw new CborSerializationException("Value too large"); + } + + return (short) this.value; + } + + /** + * Get the value as an unsigned 8-bit integer, throwing if it exceeds {@code 0xFF}. Values + * above {@link Byte#MAX_VALUE} are returned with the same bit pattern (a negative + * {@code byte}); mask with {@code & 0xFF} to recover the unsigned value. + * + * @return value */ public byte asByte() { if (Long.compareUnsigned(this.value, 0xFFL) > 0) { - throw new ArithmeticException("Value too large"); + throw new CborSerializationException("Value too large"); } return (byte) this.value; } /** - * Get number as short, throw error if does not fit. + * Get the value as a non-negative {@code int} bounded to {@link Integer#MAX_VALUE}, suitable + * for a collection size, byte-string length or index. Throws if the value would not fit in a + * valid Java array size. * - * @return number + * @return value */ - public short asShort() { - if (Long.compareUnsigned(this.value, 0xFFFFL) > 0) { - throw new ArithmeticException("Value too large"); + public int asListSize() { + if (Long.compareUnsigned(this.value, Integer.MAX_VALUE) > 0) { + throw new CborSerializationException("Value too large"); } - return (short) this.value; + return (int) this.value; } } } diff --git a/src/main/java/org/unicitylabs/sdk/serializer/cbor/CborSerializer.java b/src/main/java/org/unicitylabs/sdk/serializer/cbor/CborSerializer.java index 9cb62f52..71b328e8 100644 --- a/src/main/java/org/unicitylabs/sdk/serializer/cbor/CborSerializer.java +++ b/src/main/java/org/unicitylabs/sdk/serializer/cbor/CborSerializer.java @@ -1,5 +1,8 @@ package org.unicitylabs.sdk.serializer.cbor; +import org.unicitylabs.sdk.util.BigIntegerConverter; + +import java.math.BigInteger; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; @@ -63,6 +66,17 @@ public static byte[] encodeByteString(byte[] input) { return CborSerializer.encodeRawArray(input, input.length, CborMajorType.BYTE_STRING); } + /** + * Encode a non-negative big integer as a minimally encoded big-endian CBOR byte string; zero + * encodes as an empty byte string. + * + * @param input non-negative integer + * @return bytes + */ + public static byte[] encodeBigInteger(BigInteger input) { + return CborSerializer.encodeByteString(BigIntegerConverter.encode(input)); + } + /** * Encode text string as CBOR bytes. * diff --git a/src/main/java/org/unicitylabs/sdk/serializer/json/NetworkIdJson.java b/src/main/java/org/unicitylabs/sdk/serializer/json/NetworkIdJson.java index 6a25f4a6..4dda8212 100644 --- a/src/main/java/org/unicitylabs/sdk/serializer/json/NetworkIdJson.java +++ b/src/main/java/org/unicitylabs/sdk/serializer/json/NetworkIdJson.java @@ -67,7 +67,12 @@ public Deserializer() { */ @Override public NetworkId deserialize(JsonParser p, DeserializationContext ctx) throws IOException { - return NetworkId.fromId(p.getShortValue()); + int id = p.getIntValue(); + if (id < 1 || id > 0xFFFF) { + throw new IllegalArgumentException( + "Network identifier out of allowed 16-bit unsigned range: " + id + "."); + } + return NetworkId.fromId((short) id); } } } diff --git a/src/main/java/org/unicitylabs/sdk/smt/BranchExistsException.java b/src/main/java/org/unicitylabs/sdk/smt/BranchExistsException.java deleted file mode 100644 index c6c87aa9..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/BranchExistsException.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.unicitylabs.sdk.smt; - -/** - * Exception thrown when a branch already exists at a given path in the merkle tree. - */ -public class BranchExistsException extends Exception { - - /** - * Create exception indicating that a branch already exists at the specified path. - */ - public BranchExistsException() { - super("Branch already exists at this path."); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/CommonPath.java b/src/main/java/org/unicitylabs/sdk/smt/CommonPath.java deleted file mode 100644 index 6a4f1a6f..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/CommonPath.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.unicitylabs.sdk.smt; - -import java.math.BigInteger; -import java.util.Objects; - -/** - * Common path for two nodes in a merkle tree. - */ -public class CommonPath { - - private final BigInteger path; - private final int length; - - CommonPath(BigInteger path, int length) { - this.path = path; - this.length = length; - } - - /** - * Get common path. - * - * @return common path - */ - public BigInteger getPath() { - return this.path; - } - - /** - * Get length of the common path. - * - * @return length of the common path - */ - public int getLength() { - return this.length; - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof CommonPath)) { - return false; - } - CommonPath that = (CommonPath) o; - return length == that.length && Objects.equals(path, that.path); - } - - @Override - public int hashCode() { - return Objects.hash(path, length); - } - - /** - * Create common path for two paths. - * - * @param path1 first path - * @param path2 second path - * @return common path - */ - public static CommonPath create(BigInteger path1, BigInteger path2) { - BigInteger path = BigInteger.ONE; - BigInteger mask = BigInteger.ONE; - int length = 0; - - while (Objects.equals(path1.and(mask), path2.and(mask)) && path.compareTo(path1) < 0 - && path.compareTo(path2) < 0) { - mask = mask.shiftLeft(1); - length += 1; - path = mask.or(mask.subtract(BigInteger.ONE).and(path1)); - } - - return new CommonPath(path, length); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/LeafExistsException.java b/src/main/java/org/unicitylabs/sdk/smt/LeafExistsException.java new file mode 100644 index 00000000..e4a21423 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/smt/LeafExistsException.java @@ -0,0 +1,15 @@ +package org.unicitylabs.sdk.smt; + +/** + * Exception thrown when a sparse Merkle tree insertion targets a key that is already present in the + * tree. + */ +public class LeafExistsException extends Exception { + + /** + * Create exception indicating that a leaf already exists for the inserted key. + */ + public LeafExistsException() { + super("Leaf already exists."); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/smt/LeafOutOfBoundsException.java b/src/main/java/org/unicitylabs/sdk/smt/LeafOutOfBoundsException.java deleted file mode 100644 index 1516ae5e..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/LeafOutOfBoundsException.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.unicitylabs.sdk.smt; - -/** - * Exception when leaf is out of bounds. - */ -public class LeafOutOfBoundsException extends Exception { - - /** - * Create exception. - */ - public LeafOutOfBoundsException() { - super("Cannot extend tree through leaf."); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/MerkleTreePathVerificationResult.java b/src/main/java/org/unicitylabs/sdk/smt/MerkleTreePathVerificationResult.java deleted file mode 100644 index 982fb5a2..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/MerkleTreePathVerificationResult.java +++ /dev/null @@ -1,70 +0,0 @@ -package org.unicitylabs.sdk.smt; - -import java.util.Objects; - -/** - * Merkle tree path verification result. - */ -public class MerkleTreePathVerificationResult { - - private final boolean pathValid; - private final boolean pathIncluded; - - /** - * Create merkle tree path verification result. - * - * @param pathValid is path valid - * @param pathIncluded is path included for given state id - */ - public MerkleTreePathVerificationResult(boolean pathValid, boolean pathIncluded) { - this.pathValid = pathValid; - this.pathIncluded = pathIncluded; - } - - /** - * Is path valid. - * - * @return true if path is valid - */ - public boolean isPathValid() { - return this.pathValid; - } - - /** - * Is path included for given state id. - * - * @return true if is included - */ - public boolean isPathIncluded() { - return this.pathIncluded; - } - - /** - * Is verification successful. - * - * @return true if successful - */ - public boolean isSuccessful() { - return this.pathValid && this.pathIncluded; - } - - @Override - public String toString() { - return String.format("MerkleTreePathVerificationResult{pathValid=%b, pathIncluded=%b}", - pathValid, pathIncluded); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof MerkleTreePathVerificationResult)) { - return false; - } - MerkleTreePathVerificationResult that = (MerkleTreePathVerificationResult) o; - return pathValid == that.pathValid && pathIncluded == that.pathIncluded; - } - - @Override - public int hashCode() { - return Objects.hash(pathValid, pathIncluded); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/SparseMerkleTreePathUtils.java b/src/main/java/org/unicitylabs/sdk/smt/SparseMerkleTreePathUtils.java new file mode 100644 index 00000000..0d778c82 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/smt/SparseMerkleTreePathUtils.java @@ -0,0 +1,95 @@ +package org.unicitylabs.sdk.smt; + +import java.util.Objects; + +/** + * Path utilities for the radix sparse Merkle trees. + */ +public final class SparseMerkleTreePathUtils { + + /** + * Region size in bytes: the SMT key length, since the region holds a full 256-bit key prefix. A + * fixed protocol constant that must match the JS SDK; it is not a tunable parameter. + */ + private static final int REGION_LENGTH = 32; + + private SparseMerkleTreePathUtils() { + } + + /** + * Length of the common big-endian bit prefix shared by keys {@code a} and {@code b}, capped at + * {@code maxDepth} (depth 0 is the most significant bit of byte 0). Used to find where a new key + * bifurcates from an existing branch: pass {@code 256} for a leaf (compare the whole key) or the + * node's depth for an interior branch (its stored region is only meaningful up to that depth). + * + * @param a first key + * @param b second key + * @param maxDepth cap on the returned prefix length + * @return common-prefix bit length + * @throws NullPointerException if {@code a} or {@code b} is {@code null} + */ + public static int commonPrefixLength(byte[] a, byte[] b, int maxDepth) { + Objects.requireNonNull(a, "a cannot be null"); + Objects.requireNonNull(b, "b cannot be null"); + + int fullBytes = maxDepth / 8; + for (int i = 0; i < fullBytes; i++) { + if (a[i] != b[i]) { + return (i * 8) + Integer.numberOfLeadingZeros((a[i] ^ b[i]) & 0xff) - 24; + } + } + + int remainderBits = maxDepth % 8; + if (remainderBits > 0) { + int diff = (a[fullBytes] ^ b[fullBytes]) & (0xff << (8 - remainderBits)) & 0xff; + if (diff != 0) { + return (fullBytes * 8) + Integer.numberOfLeadingZeros(diff) - 24; + } + } + + return maxDepth; + } + + /** + * The key's first {@code depth} bits, with the remaining bits of the 32-byte array zeroed. + * + * @param key routing key + * @param depth bifurcation depth of the node + * @return 32-byte region + * @throws NullPointerException if {@code key} is {@code null} + */ + public static byte[] regionFromKey(byte[] key, int depth) { + Objects.requireNonNull(key, "key cannot be null"); + + byte[] region = new byte[REGION_LENGTH]; + int fullBytes = depth / 8; + int remainderBits = depth % 8; + + System.arraycopy(key, 0, region, 0, fullBytes); + if (remainderBits > 0) { + region[fullBytes] = (byte) (key[fullBytes] & (0xff << (8 - remainderBits))); + } + return region; + } + + /** + * Big-endian bit of {@code data} at the given depth per the Yellowpaper: depth 0 is the most + * significant bit of {@code data[0]} ({@code data[0] & 0x80}) and depth 255 is the least + * significant bit of {@code data[31]}. + * + * @param data value to read a bit from + * @param depth bit index + * @return {@code 0} or {@code 1} + * @throws IllegalArgumentException if {@code depth} is out of bounds for {@code data} + */ + public static int getBitAtDepth(byte[] data, int depth) { + Objects.requireNonNull(data, "data cannot be null"); + if (depth < 0 || depth >= data.length * 8) { + throw new IllegalArgumentException( + String.format("Depth %d is out of bounds for a %d-byte value.", depth, data.length)); + } + int byteIndex = depth / 8; + int bitInByte = depth % 8; + return ((data[byteIndex] & 0xff) >> (7 - bitInByte)) & 1; + } +} diff --git a/src/main/java/org/unicitylabs/sdk/smt/plain/Branch.java b/src/main/java/org/unicitylabs/sdk/smt/plain/Branch.java deleted file mode 100644 index 3a887f50..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/plain/Branch.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; - -import java.math.BigInteger; - -/** - * Sparse merkle tree branch structure. - */ -interface Branch { - - /** - * Get branch path from leaf to root. - * - * @return path - */ - BigInteger getPath(); - - /** - * Finalize current branch. - * - * @param hashAlgorithm hash algorithm - * @return finalized branch - */ - FinalizedBranch finalize(HashAlgorithm hashAlgorithm); -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/plain/FinalizedBranch.java b/src/main/java/org/unicitylabs/sdk/smt/plain/FinalizedBranch.java deleted file mode 100644 index 6cdd85c6..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/plain/FinalizedBranch.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import org.unicitylabs.sdk.crypto.hash.DataHash; - -/** - * Finalized branch in sparse merkle tree. - */ -interface FinalizedBranch extends Branch { - - /** - * Get hash of the branch. - * - * @return hash - */ - DataHash getHash(); -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/plain/FinalizedLeafBranch.java b/src/main/java/org/unicitylabs/sdk/smt/plain/FinalizedLeafBranch.java deleted file mode 100644 index cf7792c8..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/plain/FinalizedLeafBranch.java +++ /dev/null @@ -1,86 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import org.unicitylabs.sdk.crypto.hash.DataHash; -import org.unicitylabs.sdk.crypto.hash.DataHasher; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.util.BigIntegerConverter; - -import java.math.BigInteger; -import java.util.Arrays; -import java.util.Objects; - -/** - * Finalized leaf branch in a sparse merkle tree. - */ -class FinalizedLeafBranch implements LeafBranch, FinalizedBranch { - - private final BigInteger path; - private final byte[] value; - private final DataHash hash; - - private FinalizedLeafBranch(BigInteger path, byte[] value, DataHash hash) { - this.path = path; - this.value = Arrays.copyOf(value, value.length); - this.hash = hash; - } - - /** - * Create a finalized leaf branch. - * - * @param path path of the branch - * @param value value stored in the leaf - * @param hashAlgorithm hash algorithm to use - * @return finalized leaf branch - */ - public static FinalizedLeafBranch create( - BigInteger path, - byte[] value, - HashAlgorithm hashAlgorithm - ) { - DataHash hash = new DataHasher(hashAlgorithm) - .update( - CborSerializer.encodeArray( - CborSerializer.encodeByteString(BigIntegerConverter.encode(path)), - CborSerializer.encodeByteString(value) - ) - ) - .digest(); - - return new FinalizedLeafBranch(path, value, hash); - } - - @Override - public BigInteger getPath() { - return this.path; - } - - @Override - public byte[] getValue() { - return Arrays.copyOf(this.value, this.value.length); - } - - @Override - public DataHash getHash() { - return this.hash; - } - - @Override - public FinalizedLeafBranch finalize(HashAlgorithm hashAlgorithm) { - return this; // Already finalized - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof FinalizedLeafBranch)) { - return false; - } - FinalizedLeafBranch that = (FinalizedLeafBranch) o; - return Objects.equals(this.path, that.path) && Arrays.equals(this.value, that.value); - } - - @Override - public int hashCode() { - return Objects.hash(this.path, Arrays.hashCode(this.value)); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/plain/FinalizedNodeBranch.java b/src/main/java/org/unicitylabs/sdk/smt/plain/FinalizedNodeBranch.java deleted file mode 100644 index f176570e..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/plain/FinalizedNodeBranch.java +++ /dev/null @@ -1,111 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import org.unicitylabs.sdk.crypto.hash.DataHash; -import org.unicitylabs.sdk.crypto.hash.DataHasher; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.util.BigIntegerConverter; - -import java.math.BigInteger; -import java.util.Objects; - -/** - * Finalized node branch in a sparse merkle tree. - */ -class FinalizedNodeBranch implements NodeBranch, FinalizedBranch { - - private final BigInteger path; - private final FinalizedBranch left; - private final FinalizedBranch right; - private final DataHash hash; - - private FinalizedNodeBranch( - BigInteger path, - FinalizedBranch left, - FinalizedBranch right, - DataHash hash - ) { - this.path = path; - this.left = left; - this.right = right; - this.hash = hash; - } - - /** - * Create a finalized node branch. - * - * @param path path of the branch - * @param left left branch - * @param right right branch - * @param hashAlgorithm hash algorithm to use - * @return finalized node branch - */ - public static FinalizedNodeBranch create( - BigInteger path, - FinalizedBranch left, - FinalizedBranch right, - HashAlgorithm hashAlgorithm - ) { - DataHash hash = new DataHasher(hashAlgorithm) - .update( - CborSerializer.encodeArray( - CborSerializer.encodeByteString(BigIntegerConverter.encode(path)), - CborSerializer.encodeNullable( - left == null - ? null - : left.getHash().getData(), - CborSerializer::encodeByteString - ), - CborSerializer.encodeNullable( - right == null - ? null - : right.getHash().getData(), - CborSerializer::encodeByteString - ) - ) - ) - .digest(); - - return new FinalizedNodeBranch(path, left, right, hash); - } - - @Override - public BigInteger getPath() { - return this.path; - } - - @Override - public FinalizedBranch getLeft() { - return this.left; - } - - @Override - public FinalizedBranch getRight() { - return this.right; - } - - @Override - public DataHash getHash() { - return this.hash; - } - - @Override - public FinalizedNodeBranch finalize(HashAlgorithm hashAlgorithm) { - return this; // Already finalized - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof FinalizedNodeBranch)) { - return false; - } - FinalizedNodeBranch that = (FinalizedNodeBranch) o; - return Objects.equals(this.path, that.path) && Objects.equals(this.left, that.left) - && Objects.equals(this.right, that.right); - } - - @Override - public int hashCode() { - return Objects.hash(this.path, this.left, this.right); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/plain/LeafBranch.java b/src/main/java/org/unicitylabs/sdk/smt/plain/LeafBranch.java deleted file mode 100644 index c5752d66..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/plain/LeafBranch.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -/** - * Leaf branch in a sparse merkle tree. - */ -interface LeafBranch extends Branch { - - /** - * Get value stored in the leaf. - * - * @return value stored in the leaf - */ - byte[] getValue(); -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/plain/NodeBranch.java b/src/main/java/org/unicitylabs/sdk/smt/plain/NodeBranch.java deleted file mode 100644 index 5e98afc8..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/plain/NodeBranch.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -/** - * Node branch in merkle tree. - */ -interface NodeBranch extends Branch { - - /** - * Get left branch. - * - * @return left branch - */ - Branch getLeft(); - - /** - * Get right branch. - * - * @return right branch - */ - Branch getRight(); -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/plain/PendingLeafBranch.java b/src/main/java/org/unicitylabs/sdk/smt/plain/PendingLeafBranch.java deleted file mode 100644 index 286a13f9..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/plain/PendingLeafBranch.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; - -import java.math.BigInteger; -import java.util.Arrays; -import java.util.Objects; - -/** - * Pending leaf branch in a sparse merkle tree. - */ -class PendingLeafBranch implements LeafBranch { - - private final BigInteger path; - private final byte[] value; - - /** - * Create a pending leaf branch. - * - * @param path path of the branch - * @param value value stored in the leaf - */ - public PendingLeafBranch(BigInteger path, byte[] value) { - this.path = path; - this.value = value; - } - - @Override - public BigInteger getPath() { - return this.path; - } - - @Override - public byte[] getValue() { - return Arrays.copyOf(this.value, this.value.length); - } - - @Override - public FinalizedLeafBranch finalize(HashAlgorithm hashAlgorithm) { - return FinalizedLeafBranch.create(this.path, this.value, hashAlgorithm); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof PendingLeafBranch)) { - return false; - } - PendingLeafBranch that = (PendingLeafBranch) o; - return Objects.equals(this.path, that.path) && Objects.deepEquals(this.value, that.value); - } - - @Override - public int hashCode() { - return Objects.hash(this.path, Arrays.hashCode(this.value)); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/plain/PendingNodeBranch.java b/src/main/java/org/unicitylabs/sdk/smt/plain/PendingNodeBranch.java deleted file mode 100644 index 8b39cc0b..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/plain/PendingNodeBranch.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; - -import java.math.BigInteger; -import java.util.Objects; - -/** - * Pending node branch in a sparse merkle tree. - */ -class PendingNodeBranch implements NodeBranch { - - private final BigInteger path; - private final Branch left; - private final Branch right; - - /** - * Create a pending node branch. - * - * @param path path of the branch - * @param left left branch - * @param right right branch - */ - public PendingNodeBranch(BigInteger path, Branch left, Branch right) { - this.path = path; - this.left = left; - this.right = right; - } - - @Override - public BigInteger getPath() { - return this.path; - } - - @Override - public Branch getLeft() { - return this.left; - } - - @Override - public Branch getRight() { - return this.right; - } - - @Override - public FinalizedNodeBranch finalize(HashAlgorithm hashAlgorithm) { - return FinalizedNodeBranch.create( - this.path, - this.left.finalize(hashAlgorithm), - this.right.finalize(hashAlgorithm), - hashAlgorithm - ); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof PendingNodeBranch)) { - return false; - } - PendingNodeBranch that = (PendingNodeBranch) o; - return Objects.equals(this.path, that.path) && Objects.equals(this.left, that.left) - && Objects.equals(this.right, that.right); - } - - @Override - public int hashCode() { - return Objects.hash(this.path, this.left, this.right); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTree.java b/src/main/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTree.java deleted file mode 100644 index ce5109ab..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTree.java +++ /dev/null @@ -1,120 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.smt.BranchExistsException; -import org.unicitylabs.sdk.smt.CommonPath; -import org.unicitylabs.sdk.smt.LeafOutOfBoundsException; - -import java.math.BigInteger; -import java.util.Arrays; - -/** - * Sparse Merkle tree implementation. - */ -public class SparseMerkleTree { - - private Branch left = null; - private Branch right = null; - - private final HashAlgorithm hashAlgorithm; - - /** - * Create sparse Merkle tree with given hash algorithm. - * - * @param hashAlgorithm hash algorithm - */ - public SparseMerkleTree(HashAlgorithm hashAlgorithm) { - this.hashAlgorithm = hashAlgorithm; - } - - /** - * Add leaf to the tree at given path. - * - * @param path path of the leaf - * @param data data of the leaf - * @throws BranchExistsException if branch already exists at the path - * @throws LeafOutOfBoundsException if leaf is out of bounds - * @throws IllegalArgumentException if path is less than 1 - */ - public synchronized void addLeaf(BigInteger path, byte[] data) - throws BranchExistsException, LeafOutOfBoundsException { - if (path.compareTo(BigInteger.ONE) < 0) { - throw new IllegalArgumentException("Path must be greater than 0"); - } - - boolean isRight = path.testBit(0); - Branch branch = isRight ? this.right : this.left; - Branch result = branch != null - ? SparseMerkleTree.buildTree(branch, path, Arrays.copyOf(data, data.length)) - : new PendingLeafBranch(path, Arrays.copyOf(data, data.length)); - - if (isRight) { - this.right = result; - } else { - this.left = result; - } - } - - /** - * Calculate root of the tree. - * - * @return root node and its state - */ - public synchronized SparseMerkleTreeRootNode calculateRoot() { - FinalizedBranch left = this.left != null ? this.left.finalize(this.hashAlgorithm) : null; - FinalizedBranch right = this.right != null ? this.right.finalize(this.hashAlgorithm) : null; - this.left = left; - this.right = right; - - return SparseMerkleTreeRootNode.create(left, right, this.hashAlgorithm); - } - - private static Branch buildTree(Branch branch, BigInteger remainingPath, byte[] value) - throws BranchExistsException, LeafOutOfBoundsException { - CommonPath commonPath = CommonPath.create(remainingPath, branch.getPath()); - boolean isRight = remainingPath.shiftRight(commonPath.getLength()).testBit(0); - - if (commonPath.getPath().equals(remainingPath)) { - throw new BranchExistsException(); - } - - if (branch instanceof LeafBranch) { - if (commonPath.getPath().equals(branch.getPath())) { - throw new LeafOutOfBoundsException(); - } - - LeafBranch leafBranch = (LeafBranch) branch; - - LeafBranch oldBranch = new PendingLeafBranch( - branch.getPath().shiftRight(commonPath.getLength()), leafBranch.getValue()); - LeafBranch newBranch = new PendingLeafBranch(remainingPath.shiftRight(commonPath.getLength()), - value); - return new PendingNodeBranch(commonPath.getPath(), isRight ? oldBranch : newBranch, - isRight ? newBranch : oldBranch); - } - - NodeBranch nodeBranch = (NodeBranch) branch; - - // if node branch is split in the middle - if (commonPath.getPath().compareTo(branch.getPath()) < 0) { - LeafBranch newBranch = new PendingLeafBranch(remainingPath.shiftRight(commonPath.getLength()), - value); - NodeBranch oldBranch = new PendingNodeBranch( - branch.getPath().shiftRight(commonPath.getLength()), nodeBranch.getLeft(), - nodeBranch.getRight()); - return new PendingNodeBranch(commonPath.getPath(), isRight ? oldBranch : newBranch, - isRight ? newBranch : oldBranch); - } - - if (isRight) { - return new PendingNodeBranch(nodeBranch.getPath(), nodeBranch.getLeft(), - SparseMerkleTree.buildTree(nodeBranch.getRight(), - remainingPath.shiftRight(commonPath.getLength()), value)); - } - - return new PendingNodeBranch(nodeBranch.getPath(), - SparseMerkleTree.buildTree(nodeBranch.getLeft(), - remainingPath.shiftRight(commonPath.getLength()), value), nodeBranch.getRight()); - } -} - diff --git a/src/main/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreePath.java b/src/main/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreePath.java deleted file mode 100644 index 1dcd88e9..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreePath.java +++ /dev/null @@ -1,169 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import org.unicitylabs.sdk.crypto.hash.DataHash; -import org.unicitylabs.sdk.crypto.hash.DataHasher; -import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.smt.MerkleTreePathVerificationResult; -import org.unicitylabs.sdk.util.BigIntegerConverter; - -import java.math.BigInteger; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -/** - * Sparse merkle tree path for selected path. - */ -public class SparseMerkleTreePath { - - private final DataHash rootHash; - private final List steps; - - SparseMerkleTreePath(DataHash rootHash, List steps) { - Objects.requireNonNull(rootHash, "rootHash cannot be null"); - Objects.requireNonNull(steps, "steps cannot be null"); - - this.rootHash = rootHash; - this.steps = List.copyOf(steps); - } - - /** - * Get root hash. - * - * @return root hash - */ - public DataHash getRootHash() { - return this.rootHash; - } - - /** - * Get steps to root. - * - * @return steps - */ - public List getSteps() { - return this.steps; - } - - /** - * Verify merkle tree path against given path. - * - * @param stateId path - * @return MerkleTreePathVerificationResult - */ - public MerkleTreePathVerificationResult verify(BigInteger stateId) { - if (this.steps.isEmpty()) { - return new MerkleTreePathVerificationResult(false, false); - } - - SparseMerkleTreePathStep step = this.steps.get(0); - byte[] currentData; - BigInteger currentPath = step.getPath(); - if (step.getPath().compareTo(BigInteger.ONE) > 0) { - DataHash hash = new DataHasher(this.rootHash.getAlgorithm()) - .update( - CborSerializer.encodeArray( - CborSerializer.encodeByteString(BigIntegerConverter.encode(step.getPath())), - CborSerializer.encodeNullable( - step.getData().orElse(null), - CborSerializer::encodeByteString - ) - ) - ) - .digest(); - - currentData = hash.getData(); - } else { - currentPath = BigInteger.ONE; - currentData = step.getData().orElse(null); - } - - SparseMerkleTreePathStep previousStep = step; - for (int i = 1; i < this.steps.size(); i++) { - step = this.steps.get(i); - boolean isRight = previousStep.getPath().testBit(0); - - byte[] left = isRight ? step.getData().orElse(null) : currentData; - byte[] right = isRight ? currentData : step.getData().orElse(null); - - DataHash hash = new DataHasher(this.rootHash.getAlgorithm()) - .update( - CborSerializer.encodeArray( - CborSerializer.encodeByteString(BigIntegerConverter.encode(step.getPath())), - CborSerializer.encodeNullable(left, CborSerializer::encodeByteString), - CborSerializer.encodeNullable(right, CborSerializer::encodeByteString) - ) - ) - .digest(); - - currentData = hash.getData(); - - int length = step.getPath().bitLength() - 1; - if (length < 0) { - return new MerkleTreePathVerificationResult(false, false); - } - currentPath = currentPath.shiftLeft(length) - .or(step.getPath().and(BigInteger.ONE.shiftLeft(length).subtract(BigInteger.ONE))); - previousStep = step; - } - - boolean pathValid = currentData != null - && this.rootHash.equals(new DataHash(this.rootHash.getAlgorithm(), currentData)); - boolean pathIncluded = currentPath.compareTo(stateId) == 0; - - return new MerkleTreePathVerificationResult(pathValid, pathIncluded); - } - - /** - * Deserialize sparse merkle tree path from CBOR bytes. - * - * @param bytes CBOR bytes - * @return path - */ - public static SparseMerkleTreePath fromCbor(byte[] bytes) { - List data = CborDeserializer.decodeArray(bytes, 2); - - return new SparseMerkleTreePath( - DataHash.fromCbor(data.get(0)), - CborDeserializer.decodeArray(data.get(1)).stream() - .map(SparseMerkleTreePathStep::fromCbor) - .collect(Collectors.toList()) - ); - } - - /** - * Serialize sparse merkle tree path to CBOR bytes. - * - * @return CBOR bytes - */ - public byte[] toCbor() { - return CborSerializer.encodeArray( - this.rootHash.toCbor(), - CborSerializer.encodeArray( - this.steps.stream() - .map(SparseMerkleTreePathStep::toCbor) - .toArray(byte[][]::new) - ) - ); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof SparseMerkleTreePath)) { - return false; - } - SparseMerkleTreePath that = (SparseMerkleTreePath) o; - return Objects.equals(this.rootHash, that.rootHash) && Objects.equals(this.steps, that.steps); - } - - @Override - public int hashCode() { - return Objects.hash(this.rootHash, this.steps); - } - - @Override - public String toString() { - return String.format("MerkleTreePath{rootHash=%s, steps=%s}", this.rootHash, this.steps); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreePathStep.java b/src/main/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreePathStep.java deleted file mode 100644 index 931c8500..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreePathStep.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.util.BigIntegerConverter; -import org.unicitylabs.sdk.util.HexConverter; - -import java.math.BigInteger; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -/** - * Sparse Merkle tree path step. - */ -public class SparseMerkleTreePathStep { - - private final BigInteger path; - private final byte[] data; - - /** - * Create sparse Merkle tree path step. - * - * @param path step path, must be greater than or equal to zero - * @param data step data - */ - public SparseMerkleTreePathStep( - BigInteger path, - byte[] data - ) { - Objects.requireNonNull(path, "path cannot be null"); - if (path.compareTo(BigInteger.ZERO) < 0) { - throw new IllegalArgumentException("path should be non negative"); - } - - this.path = path; - this.data = data; - } - - /** - * Get path. - * - * @return step path - */ - public BigInteger getPath() { - return this.path; - } - - /** - * Get data. - * - * @return step data - */ - public Optional getData() { - return Optional.ofNullable(this.data); - } - - /** - * Deserialize sparse Merkle tree path step from CBOR bytes. - * - * @param bytes CBOR bytes - * @return sparse Merkle tree path step - */ - public static SparseMerkleTreePathStep fromCbor(byte[] bytes) { - List data = CborDeserializer.decodeArray(bytes, 2); - - return new SparseMerkleTreePathStep( - BigIntegerConverter.decode(CborDeserializer.decodeByteString(data.get(0))), - CborDeserializer.decodeNullable(data.get(1), CborDeserializer::decodeByteString) - ); - } - - /** - * Serialize sparse Merkle tree path step to CBOR bytes. - * - * @return CBOR bytes - */ - public byte[] toCbor() { - return CborSerializer.encodeArray( - CborSerializer.encodeByteString(BigIntegerConverter.encode(this.path)), - CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString) - ); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof SparseMerkleTreePathStep)) { - return false; - } - SparseMerkleTreePathStep that = (SparseMerkleTreePathStep) o; - return Objects.equals(this.path, that.path) && Arrays.equals(this.data, that.data); - } - - @Override - public int hashCode() { - return Objects.hash(this.path, Arrays.hashCode(this.data)); - } - - @Override - public String toString() { - return String.format( - "MerkleTreePathStep{path=%s, data=%s}", - this.path.toString(2), - this.data == null ? "null" : HexConverter.encode(this.data) - ); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreeRootNode.java b/src/main/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreeRootNode.java deleted file mode 100644 index e1dbece3..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreeRootNode.java +++ /dev/null @@ -1,127 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import org.unicitylabs.sdk.crypto.hash.DataHash; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.smt.CommonPath; - -import java.math.BigInteger; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -/** - * Sparse merkle tree state for given root. - */ -public class SparseMerkleTreeRootNode { - - private final FinalizedNodeBranch root; - - private SparseMerkleTreeRootNode(FinalizedNodeBranch root) { - this.root = root; - } - - static SparseMerkleTreeRootNode create( - FinalizedBranch left, - FinalizedBranch right, - HashAlgorithm hashAlgorithm - ) { - return new SparseMerkleTreeRootNode( - FinalizedNodeBranch.create(BigInteger.ONE, left, right, hashAlgorithm) - ); - } - - /** - * Get root hash. - * - * @return root hash - */ - public DataHash getRootHash() { - return this.root.getHash(); - } - - /** - * Get merkle tree path for requested path. - * - * @param path path - * @return merkle tree path - */ - public SparseMerkleTreePath getPath(BigInteger path) { - return new SparseMerkleTreePath( - this.root.getHash(), - SparseMerkleTreeRootNode.generatePath(path, this.root) - ); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof SparseMerkleTreeRootNode)) { - return false; - } - SparseMerkleTreeRootNode that = (SparseMerkleTreeRootNode) o; - return Objects.equals(this.root, that.root); - } - - @Override - public int hashCode() { - return Objects.hash(this.root); - } - - private static List generatePath( - BigInteger remainingPath, - FinalizedBranch parent - ) { - if (parent instanceof LeafBranch) { - LeafBranch leaf = (LeafBranch) parent; - return List.of(new SparseMerkleTreePathStep(leaf.getPath(), leaf.getValue())); - } - - FinalizedNodeBranch node = (FinalizedNodeBranch) parent; - CommonPath commonPath = CommonPath.create(remainingPath, parent.getPath()); - remainingPath = remainingPath.shiftRight(commonPath.getLength()); - - if (commonPath.getPath().compareTo(parent.getPath()) != 0 - || remainingPath.compareTo(BigInteger.ONE) == 0) { - return List.of( - new SparseMerkleTreePathStep( - BigInteger.ZERO, - node.getLeft() == null - ? null - : node.getLeft().getHash().getData() - ), - new SparseMerkleTreePathStep( - node.getPath(), - node.getRight() == null - ? null - : node.getRight().getHash().getData() - ) - ); - } - - boolean isRight = remainingPath.testBit(0); - FinalizedBranch branch = isRight ? node.getRight() : node.getLeft(); - FinalizedBranch siblingBranch = isRight ? node.getLeft() : node.getRight(); - - SparseMerkleTreePathStep step = new SparseMerkleTreePathStep( - node.getPath(), - siblingBranch == null ? null : siblingBranch.getHash().getData() - ); - - if (branch == null) { - return List.of( - new SparseMerkleTreePathStep( - isRight ? BigInteger.ONE : BigInteger.ZERO, - null - ), - step - ); - } - - List list = new ArrayList<>( - SparseMerkleTreeRootNode.generatePath(remainingPath, branch) - ); - - list.add(step); - - return List.copyOf(list); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/radix/Branch.java b/src/main/java/org/unicitylabs/sdk/smt/radix/Branch.java index 0b3beb09..e7a9f386 100644 --- a/src/main/java/org/unicitylabs/sdk/smt/radix/Branch.java +++ b/src/main/java/org/unicitylabs/sdk/smt/radix/Branch.java @@ -2,19 +2,25 @@ import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import java.math.BigInteger; - /** * Sparse merkle tree branch structure. */ -public interface Branch { +interface Branch { + + /** + * Get the absolute bifurcation depth of this branch. + * + * @return depth + */ + int getDepth(); /** - * Get branch path from leaf to root. + * Depth at which {@code key} diverges from this branch, capped at the branch's own depth. * - * @return path + * @param key key being inserted + * @return common-prefix depth */ - BigInteger getPath(); + int calculateSplitDepth(byte[] key); /** * Finalize current branch. diff --git a/src/main/java/org/unicitylabs/sdk/smt/radix/FinalizedBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radix/FinalizedBranch.java index 603090b1..55f9ac7f 100644 --- a/src/main/java/org/unicitylabs/sdk/smt/radix/FinalizedBranch.java +++ b/src/main/java/org/unicitylabs/sdk/smt/radix/FinalizedBranch.java @@ -5,7 +5,7 @@ /** * Finalized branch in sparse merkle tree. */ -public interface FinalizedBranch extends Branch { +interface FinalizedBranch extends Branch { /** * Get hash of the branch. diff --git a/src/main/java/org/unicitylabs/sdk/smt/radix/FinalizedLeafBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radix/FinalizedLeafBranch.java index 6e282c5a..109961a0 100644 --- a/src/main/java/org/unicitylabs/sdk/smt/radix/FinalizedLeafBranch.java +++ b/src/main/java/org/unicitylabs/sdk/smt/radix/FinalizedLeafBranch.java @@ -3,28 +3,33 @@ import org.unicitylabs.sdk.crypto.hash.DataHash; import org.unicitylabs.sdk.crypto.hash.DataHasher; import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; -import java.math.BigInteger; import java.util.Arrays; import java.util.Objects; -public class FinalizedLeafBranch implements LeafBranch, FinalizedBranch { +class FinalizedLeafBranch implements LeafBranch, FinalizedBranch { - private final BigInteger path; private final byte[] key; private final byte[] value; + private final int depth; private final DataHash hash; - private FinalizedLeafBranch(BigInteger path, byte[] key, byte[] value, DataHash hash) { - this.path = path; - this.key = Arrays.copyOf(key, key.length); - this.value = Arrays.copyOf(value, value.length); + private FinalizedLeafBranch(byte[] key, byte[] value, DataHash hash) { + this.key = key; + this.value = value; + this.depth = key.length * 8; this.hash = hash; } @Override - public BigInteger getPath() { - return this.path; + public int getDepth() { + return this.depth; + } + + @Override + public int calculateSplitDepth(byte[] key) { + return SparseMerkleTreePathUtils.commonPrefixLength(key, this.key, this.depth); } @Override @@ -53,12 +58,12 @@ public boolean equals(Object o) { return false; } FinalizedLeafBranch that = (FinalizedLeafBranch) o; - return Objects.equals(this.path, that.path) && Arrays.equals(this.value, that.value); + return Arrays.equals(this.key, that.key) && Arrays.equals(this.value, that.value); } @Override public int hashCode() { - return Objects.hash(this.path, Arrays.hashCode(this.value)); + return Objects.hash(Arrays.hashCode(this.key), Arrays.hashCode(this.value)); } public static FinalizedLeafBranch fromPendingLeaf( @@ -75,6 +80,6 @@ public static FinalizedLeafBranch fromPendingLeaf( .update(value) .digest(); - return new FinalizedLeafBranch(leaf.getPath(), key, value, hash); + return new FinalizedLeafBranch(key, value, hash); } } diff --git a/src/main/java/org/unicitylabs/sdk/smt/radix/FinalizedNodeBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radix/FinalizedNodeBranch.java index 3e3d0954..aaff293b 100644 --- a/src/main/java/org/unicitylabs/sdk/smt/radix/FinalizedNodeBranch.java +++ b/src/main/java/org/unicitylabs/sdk/smt/radix/FinalizedNodeBranch.java @@ -3,19 +3,20 @@ import org.unicitylabs.sdk.crypto.hash.DataHash; import org.unicitylabs.sdk.crypto.hash.DataHasher; import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; import org.unicitylabs.sdk.util.LongConverter; -import java.math.BigInteger; +import java.util.Arrays; -public class FinalizedNodeBranch implements NodeBranch, FinalizedBranch { - private final BigInteger path; +class FinalizedNodeBranch implements NodeBranch, FinalizedBranch { + private final byte[] path; private final int depth; private final FinalizedBranch left; private final FinalizedBranch right; private final DataHash hash; private FinalizedNodeBranch( - BigInteger path, + byte[] path, int depth, FinalizedBranch left, FinalizedBranch right, @@ -29,8 +30,8 @@ private FinalizedNodeBranch( } @Override - public BigInteger getPath() { - return this.path; + public byte[] getPath() { + return Arrays.copyOf(this.path, this.path.length); } @Override @@ -38,6 +39,11 @@ public int getDepth() { return this.depth; } + @Override + public int calculateSplitDepth(byte[] key) { + return SparseMerkleTreePathUtils.commonPrefixLength(key, this.path, this.depth); + } + @Override public FinalizedBranch getLeft() { return this.left; @@ -72,6 +78,7 @@ public static FinalizedNodeBranch fromPendingNode(HashAlgorithm hashAlgorithm, P DataHash hash = new DataHasher(hashAlgorithm) .update(new byte[]{0x01}) .update(LongConverter.encode(node.getDepth())) + .update(node.getPath()) .update(left.getHash().getData()) .update(right.getHash().getData()) .digest(); @@ -79,6 +86,16 @@ public static FinalizedNodeBranch fromPendingNode(HashAlgorithm hashAlgorithm, P return new FinalizedNodeBranch(node.getPath(), node.getDepth(), left, right, hash); } + @Override + public PendingNodeBranch withLeftBranch(Branch left) { + return new PendingNodeBranch(this.path, this.depth, left, this.right); + } + + @Override + public PendingNodeBranch withRightBranch(Branch right) { + return new PendingNodeBranch(this.path, this.depth, this.left, right); + } + @Override public FinalizedNodeBranch finalize(HashAlgorithm hashAlgorithm) { return this; diff --git a/src/main/java/org/unicitylabs/sdk/smt/radix/LeafBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radix/LeafBranch.java index 4cda9b5d..ab8517dc 100644 --- a/src/main/java/org/unicitylabs/sdk/smt/radix/LeafBranch.java +++ b/src/main/java/org/unicitylabs/sdk/smt/radix/LeafBranch.java @@ -3,7 +3,7 @@ /** * Leaf branch in a sparse merkle tree. */ -public interface LeafBranch extends Branch { +interface LeafBranch extends Branch { byte[] getKey(); diff --git a/src/main/java/org/unicitylabs/sdk/smt/radix/NodeBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radix/NodeBranch.java index 109130fd..f0c4091f 100644 --- a/src/main/java/org/unicitylabs/sdk/smt/radix/NodeBranch.java +++ b/src/main/java/org/unicitylabs/sdk/smt/radix/NodeBranch.java @@ -3,9 +3,14 @@ /** * Node branch in merkle tree. */ -public interface NodeBranch extends Branch { +interface NodeBranch extends Branch { - int getDepth(); + /** + * Get the node's committed region: its {@code depth}-bit key prefix with the suffix zeroed. + * + * @return region + */ + byte[] getPath(); /** * Get left branch. @@ -20,4 +25,21 @@ public interface NodeBranch extends Branch { * @return right branch */ Branch getRight(); + + /** + * Derive a pending node with {@code left} as its left child, reusing this node's committed region. + * + * @param left replacement left child + * @return new pending node + */ + PendingNodeBranch withLeftBranch(Branch left); + + /** + * Derive a pending node with {@code right} as its right child, reusing this node's committed + * region. + * + * @param right replacement right child + * @return new pending node + */ + PendingNodeBranch withRightBranch(Branch right); } diff --git a/src/main/java/org/unicitylabs/sdk/smt/radix/PendingLeafBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radix/PendingLeafBranch.java index 3b4c9d1d..e447b4d9 100644 --- a/src/main/java/org/unicitylabs/sdk/smt/radix/PendingLeafBranch.java +++ b/src/main/java/org/unicitylabs/sdk/smt/radix/PendingLeafBranch.java @@ -1,25 +1,30 @@ package org.unicitylabs.sdk.smt.radix; import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; -import java.math.BigInteger; import java.util.Arrays; import java.util.Objects; -public class PendingLeafBranch implements LeafBranch { - private final BigInteger path; +class PendingLeafBranch implements LeafBranch { private final byte[] key; private final byte[] value; + private final int depth; - public PendingLeafBranch(BigInteger path, byte[] key, byte[] value) { - this.path = path; - this.key = Arrays.copyOf(key, key.length); - this.value = Arrays.copyOf(value, value.length); + public PendingLeafBranch(byte[] key, byte[] value) { + this.key = key; + this.value = value; + this.depth = key.length * 8; } @Override - public BigInteger getPath() { - return this.path; + public int getDepth() { + return this.depth; + } + + @Override + public int calculateSplitDepth(byte[] key) { + return SparseMerkleTreePathUtils.commonPrefixLength(key, this.key, this.depth); } @Override @@ -44,11 +49,11 @@ public boolean equals(Object o) { } PendingLeafBranch that = (PendingLeafBranch) o; - return Objects.equals(this.path, that.path) && Objects.deepEquals(this.value, that.value); + return Arrays.equals(this.key, that.key) && Arrays.equals(this.value, that.value); } @Override public int hashCode() { - return Objects.hash(this.path, Arrays.hashCode(this.value)); + return Objects.hash(Arrays.hashCode(this.key), Arrays.hashCode(this.value)); } } diff --git a/src/main/java/org/unicitylabs/sdk/smt/radix/PendingNodeBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radix/PendingNodeBranch.java index 6c34f49c..523efca9 100644 --- a/src/main/java/org/unicitylabs/sdk/smt/radix/PendingNodeBranch.java +++ b/src/main/java/org/unicitylabs/sdk/smt/radix/PendingNodeBranch.java @@ -1,16 +1,17 @@ package org.unicitylabs.sdk.smt.radix; import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; -import java.math.BigInteger; +import java.util.Arrays; -public class PendingNodeBranch implements NodeBranch { - private final BigInteger path; +class PendingNodeBranch implements NodeBranch { + private final byte[] path; private final int depth; private final Branch left; private final Branch right; - public PendingNodeBranch(BigInteger path, int depth, Branch left, Branch right) { + public PendingNodeBranch(byte[] path, int depth, Branch left, Branch right) { this.path = path; this.depth = depth; this.left = left; @@ -18,8 +19,8 @@ public PendingNodeBranch(BigInteger path, int depth, Branch left, Branch right) } @Override - public BigInteger getPath() { - return this.path; + public byte[] getPath() { + return Arrays.copyOf(this.path, this.path.length); } @Override @@ -27,6 +28,11 @@ public int getDepth() { return this.depth; } + @Override + public int calculateSplitDepth(byte[] key) { + return SparseMerkleTreePathUtils.commonPrefixLength(key, this.path, this.depth); + } + @Override public Branch getLeft() { return this.left; @@ -37,6 +43,16 @@ public Branch getRight() { return this.right; } + @Override + public PendingNodeBranch withLeftBranch(Branch left) { + return new PendingNodeBranch(this.path, this.depth, left, this.right); + } + + @Override + public PendingNodeBranch withRightBranch(Branch right) { + return new PendingNodeBranch(this.path, this.depth, this.left, right); + } + @Override public FinalizedNodeBranch finalize(HashAlgorithm hashAlgorithm) { return FinalizedNodeBranch.fromPendingNode( diff --git a/src/main/java/org/unicitylabs/sdk/smt/radix/SparseMerkleTree.java b/src/main/java/org/unicitylabs/sdk/smt/radix/SparseMerkleTree.java index 99205dfc..f14c6a9b 100644 --- a/src/main/java/org/unicitylabs/sdk/smt/radix/SparseMerkleTree.java +++ b/src/main/java/org/unicitylabs/sdk/smt/radix/SparseMerkleTree.java @@ -1,12 +1,11 @@ package org.unicitylabs.sdk.smt.radix; import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.smt.BranchExistsException; -import org.unicitylabs.sdk.smt.CommonPath; -import org.unicitylabs.sdk.smt.LeafOutOfBoundsException; -import org.unicitylabs.sdk.util.BitString; +import org.unicitylabs.sdk.smt.LeafExistsException; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; -import java.math.BigInteger; +import java.util.Arrays; +import java.util.Objects; /** * Sparse Merkle tree implementation. @@ -30,25 +29,27 @@ public SparseMerkleTree(HashAlgorithm hashAlgorithm) { /** * Add leaf to the tree at given path. * - * @param key path of the leaf - * @param data data of the leaf - * @throws BranchExistsException if branch already exists at the path - * @throws LeafOutOfBoundsException if leaf is out of bounds - * @throws IllegalArgumentException if path is less than 1 + * @param key path of the leaf; must be a 32-byte key + * @param data data of the leaf; arbitrary-length byte string + * @throws LeafExistsException if a leaf already exists for the key + * @throws IllegalArgumentException if the key is not 32 bytes */ - public synchronized void addLeaf(byte[] key, byte[] data) - throws BranchExistsException, LeafOutOfBoundsException { - BigInteger path = BitString.fromBytesReversedLSB(key).toBigInteger(); + public synchronized void addLeaf(byte[] key, byte[] data) throws LeafExistsException { + Objects.requireNonNull(key, "key cannot be null"); + Objects.requireNonNull(data, "data cannot be null"); - if (path.compareTo(BigInteger.ONE) <= 0) { - throw new IllegalArgumentException("Path must be greater than 0"); + if (key.length != 32) { + throw new IllegalArgumentException("Key must be 32 bytes long."); } - boolean isRight = path.testBit(0); + key = Arrays.copyOf(key, key.length); + data = Arrays.copyOf(data, data.length); + + boolean isRight = SparseMerkleTreePathUtils.getBitAtDepth(key, 0) == 1; Branch branch = isRight ? this.right : this.left; Branch result = branch != null - ? SparseMerkleTree.buildTree(branch, path, 0, key, data) - : new PendingLeafBranch(path, key, data); + ? SparseMerkleTree.buildTree(branch, key, data) + : new PendingLeafBranch(key, data); if (isRight) { this.right = result; @@ -62,65 +63,48 @@ public synchronized void addLeaf(byte[] key, byte[] data) * * @return root node and its state */ - public synchronized FinalizedNodeBranch calculateRoot() { + public synchronized SparseMerkleTreeRootNode calculateRoot() { FinalizedBranch left = this.left != null ? this.left.finalize(this.hashAlgorithm) : null; FinalizedBranch right = this.right != null ? this.right.finalize(this.hashAlgorithm) : null; this.left = left; this.right = right; - return new PendingNodeBranch(BigInteger.ONE, 0, left, right).finalize(hashAlgorithm); + FinalizedNodeBranch root = new PendingNodeBranch(new byte[32], 0, left, right) + .finalize(hashAlgorithm); + return SparseMerkleTreeRootNode.create(root); } - private static Branch buildTree(Branch branch, BigInteger remainingPath, int depth, byte[] key, - byte[] value) throws BranchExistsException, LeafOutOfBoundsException { - CommonPath commonPath = CommonPath.create(remainingPath, branch.getPath()); - int commonPathLength = commonPath.getLength(); - boolean isRight = remainingPath.shiftRight(commonPathLength).testBit(0); - - if (commonPath.getPath().equals(remainingPath)) { - throw new BranchExistsException(); - } - + private static Branch buildTree(Branch branch, byte[] key, byte[] value) + throws LeafExistsException { if (branch instanceof LeafBranch) { - if (commonPath.getPath().equals(branch.getPath())) { - throw new LeafOutOfBoundsException(); + int depth = branch.calculateSplitDepth(key); + if (depth == branch.getDepth()) { + throw new LeafExistsException(); } - LeafBranch leafBranch = (LeafBranch) branch; - - LeafBranch oldBranch = new PendingLeafBranch( - branch.getPath().shiftRight(commonPathLength), leafBranch.getKey(), - leafBranch.getValue()); - LeafBranch newBranch = new PendingLeafBranch( - remainingPath.shiftRight(commonPathLength), key, value); - return new PendingNodeBranch(commonPath.getPath(), depth + commonPathLength, - isRight ? oldBranch : newBranch, isRight ? newBranch : oldBranch); + boolean isRight = SparseMerkleTreePathUtils.getBitAtDepth(key, depth) == 1; + LeafBranch newBranch = new PendingLeafBranch(key, value); + return new PendingNodeBranch(SparseMerkleTreePathUtils.regionFromKey(key, depth), depth, + isRight ? branch : newBranch, isRight ? newBranch : branch); } NodeBranch nodeBranch = (NodeBranch) branch; + int depth = branch.calculateSplitDepth(key); + boolean isRight = SparseMerkleTreePathUtils.getBitAtDepth(key, depth) == 1; // if node branch is split in the middle - if (commonPath.getPath().compareTo(branch.getPath()) < 0) { - LeafBranch newBranch = new PendingLeafBranch( - remainingPath.shiftRight(commonPathLength), key, value); - NodeBranch oldBranch = new PendingNodeBranch( - branch.getPath().shiftRight(commonPathLength), nodeBranch.getDepth(), - nodeBranch.getLeft(), nodeBranch.getRight()); - return new PendingNodeBranch(commonPath.getPath(), depth + commonPathLength, - isRight ? oldBranch : newBranch, isRight ? newBranch : oldBranch); + if (depth < branch.getDepth()) { + LeafBranch newBranch = new PendingLeafBranch(key, value); + return new PendingNodeBranch(SparseMerkleTreePathUtils.regionFromKey(key, depth), depth, + isRight ? branch : newBranch, isRight ? newBranch : branch); } if (isRight) { - return new PendingNodeBranch(nodeBranch.getPath(), nodeBranch.getDepth(), - nodeBranch.getLeft(), - SparseMerkleTree.buildTree(nodeBranch.getRight(), - remainingPath.shiftRight(commonPathLength), depth + commonPathLength, key, value)); + return nodeBranch.withRightBranch( + SparseMerkleTree.buildTree(nodeBranch.getRight(), key, value)); } - return new PendingNodeBranch(nodeBranch.getPath(), nodeBranch.getDepth(), - SparseMerkleTree.buildTree(nodeBranch.getLeft(), - remainingPath.shiftRight(commonPathLength), depth + commonPathLength, key, value), - nodeBranch.getRight()); + return nodeBranch.withLeftBranch( + SparseMerkleTree.buildTree(nodeBranch.getLeft(), key, value)); } } - diff --git a/src/main/java/org/unicitylabs/sdk/smt/radix/SparseMerkleTreeRootNode.java b/src/main/java/org/unicitylabs/sdk/smt/radix/SparseMerkleTreeRootNode.java new file mode 100644 index 00000000..df657d81 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/smt/radix/SparseMerkleTreeRootNode.java @@ -0,0 +1,104 @@ +package org.unicitylabs.sdk.smt.radix; + +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; +import org.unicitylabs.sdk.util.HexConverter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * Root of a radix sparse Merkle tree: the committed root hash plus the ability to enumerate the + * root-to-leaf sibling path for a key. The tree's branch nodes are an internal detail of this + * package; callers see only the root hash and the sibling path. + */ +public class SparseMerkleTreeRootNode { + + private final FinalizedNodeBranch root; + + private SparseMerkleTreeRootNode(FinalizedNodeBranch root) { + this.root = root; + } + + static SparseMerkleTreeRootNode create(FinalizedNodeBranch root) { + return new SparseMerkleTreeRootNode(root); + } + + /** + * Get the root hash. + * + * @return root hash + */ + public DataHash getHash() { + return this.root.getHash(); + } + + /** + * Enumerate the sibling entries on the path from the root to the leaf with the given key, ordered + * from the root down to the leaf. + * + * @param key 32-byte leaf key + * @return sibling entries from the root down to the leaf + * @throws IllegalArgumentException if the key is not present in the tree + */ + public List getPath(byte[] key) { + Objects.requireNonNull(key, "key cannot be null"); + + List siblings = new ArrayList<>(); + FinalizedBranch node = this.root; + while (node != null) { + if (node instanceof FinalizedLeafBranch) { + if (!Arrays.equals(((FinalizedLeafBranch) node).getKey(), key)) { + throw new IllegalArgumentException( + String.format("Leaf not found for key: %s", HexConverter.encode(key))); + } + return siblings; + } + + FinalizedNodeBranch branch = (FinalizedNodeBranch) node; + int depth = branch.getDepth(); + boolean isRight = SparseMerkleTreePathUtils.getBitAtDepth(key, depth) == 1; + FinalizedBranch sibling = isRight ? branch.getLeft() : branch.getRight(); + if (sibling != null) { + siblings.add(new Sibling(depth, sibling.getHash())); + } + node = isRight ? branch.getRight() : branch.getLeft(); + } + + throw new IllegalArgumentException( + String.format("Leaf not found for key: %s", HexConverter.encode(key))); + } + + /** + * One sibling entry of a radix sparse Merkle tree inclusion path. + */ + public static final class Sibling { + private final int depth; + private final DataHash hash; + + private Sibling(int depth, DataHash hash) { + this.depth = depth; + this.hash = hash; + } + + /** + * Get the bifurcation depth at which this sibling hangs. + * + * @return depth + */ + public int getDepth() { + return this.depth; + } + + /** + * Get the sibling subtree hash. + * + * @return hash + */ + public DataHash getHash() { + return this.hash; + } + } +} diff --git a/src/main/java/org/unicitylabs/sdk/smt/radixsum/Branch.java b/src/main/java/org/unicitylabs/sdk/smt/radixsum/Branch.java new file mode 100644 index 00000000..b5d24329 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/smt/radixsum/Branch.java @@ -0,0 +1,32 @@ +package org.unicitylabs.sdk.smt.radixsum; + +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; + +/** + * Radix sparse Merkle sum tree branch structure. + */ +interface Branch { + + /** + * Get the absolute bifurcation depth of this branch. + * + * @return depth + */ + int getDepth(); + + /** + * Depth at which {@code key} diverges from this branch, capped at the branch's own depth. + * + * @param key key being inserted + * @return common-prefix depth + */ + int calculateSplitDepth(byte[] key); + + /** + * Finalize current branch. + * + * @param hashAlgorithm hash algorithm + * @return finalized branch + */ + FinalizedBranch finalize(HashAlgorithm hashAlgorithm); +} diff --git a/src/main/java/org/unicitylabs/sdk/smt/sum/FinalizedBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radixsum/FinalizedBranch.java similarity index 56% rename from src/main/java/org/unicitylabs/sdk/smt/sum/FinalizedBranch.java rename to src/main/java/org/unicitylabs/sdk/smt/radixsum/FinalizedBranch.java index 21631416..ca254ddd 100644 --- a/src/main/java/org/unicitylabs/sdk/smt/sum/FinalizedBranch.java +++ b/src/main/java/org/unicitylabs/sdk/smt/radixsum/FinalizedBranch.java @@ -1,11 +1,11 @@ -package org.unicitylabs.sdk.smt.sum; +package org.unicitylabs.sdk.smt.radixsum; import org.unicitylabs.sdk.crypto.hash.DataHash; import java.math.BigInteger; /** - * Finalized branch in sparse merkle sum tree. + * Finalized branch in a radix sparse Merkle sum tree. */ interface FinalizedBranch extends Branch { @@ -17,9 +17,9 @@ interface FinalizedBranch extends Branch { DataHash getHash(); /** - * Get counter of the branch. + * Get the sum committed by this branch. * - * @return counter + * @return sum */ - BigInteger getCounter(); + BigInteger getValue(); } diff --git a/src/main/java/org/unicitylabs/sdk/smt/radixsum/FinalizedLeafBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radixsum/FinalizedLeafBranch.java new file mode 100644 index 00000000..22c6b6c9 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/smt/radixsum/FinalizedLeafBranch.java @@ -0,0 +1,81 @@ +package org.unicitylabs.sdk.smt.radixsum; + +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.DataHasher; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; +import org.unicitylabs.sdk.util.BigIntegerConverter; + +import java.math.BigInteger; +import java.util.Arrays; + +/** + * Finalized leaf in a radix sparse Merkle sum tree. The leaf hash is + * {@code SHA-256(0x10 || key || data || u256(value))}, where {@code u256} is the 32-byte + * big-endian encoding of the leaf amount. + */ +class FinalizedLeafBranch implements LeafBranch, FinalizedBranch { + + private final byte[] key; + private final byte[] data; + private final BigInteger value; + private final int depth; + private final DataHash hash; + + private FinalizedLeafBranch(byte[] key, byte[] data, BigInteger value, DataHash hash) { + this.key = key; + this.data = data; + this.value = value; + this.depth = key.length * 8; + this.hash = hash; + } + + public static FinalizedLeafBranch fromPendingLeaf(HashAlgorithm hashAlgorithm, PendingLeafBranch leaf) { + byte[] key = leaf.getKey(); + byte[] data = leaf.getData(); + + DataHash hash = new DataHasher(hashAlgorithm) + .update(new byte[]{0x10}) + .update(key) + .update(data) + .update(BigIntegerConverter.encode(leaf.getValue(), 32)) + .digest(); + + return new FinalizedLeafBranch(key, data, leaf.getValue(), hash); + } + + @Override + public int getDepth() { + return this.depth; + } + + @Override + public int calculateSplitDepth(byte[] key) { + return SparseMerkleTreePathUtils.commonPrefixLength(key, this.key, this.depth); + } + + @Override + public byte[] getKey() { + return Arrays.copyOf(this.key, this.key.length); + } + + @Override + public byte[] getData() { + return Arrays.copyOf(this.data, this.data.length); + } + + @Override + public BigInteger getValue() { + return this.value; + } + + @Override + public DataHash getHash() { + return this.hash; + } + + @Override + public FinalizedLeafBranch finalize(HashAlgorithm hashAlgorithm) { + return this; + } +} diff --git a/src/main/java/org/unicitylabs/sdk/smt/radixsum/FinalizedNodeBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radixsum/FinalizedNodeBranch.java new file mode 100644 index 00000000..1d2b21cf --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/smt/radixsum/FinalizedNodeBranch.java @@ -0,0 +1,107 @@ +package org.unicitylabs.sdk.smt.radixsum; + +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.DataHasher; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; +import org.unicitylabs.sdk.util.BigIntegerConverter; + +import java.math.BigInteger; +import java.util.Arrays; + +/** + * Finalized interior node in a radix sparse Merkle sum tree. The node hash is + * {@code SHA-256(0x11 || u8(depth) || hL || u256(vL) || hR || u256(vR))} and the node sum is + * {@code vL + vR}, computed with a checked 256-bit addition. + */ +class FinalizedNodeBranch implements NodeBranch, FinalizedBranch { + + private static final BigInteger SUM_LIMIT = BigInteger.ONE.shiftLeft(256); + + private final byte[] path; + private final int depth; + private final FinalizedBranch left; + private final FinalizedBranch right; + private final BigInteger value; + private final DataHash hash; + + private FinalizedNodeBranch(byte[] path, int depth, FinalizedBranch left, + FinalizedBranch right, BigInteger value, DataHash hash) { + this.path = path; + this.depth = depth; + this.left = left; + this.right = right; + this.value = value; + this.hash = hash; + } + + public static FinalizedNodeBranch fromPendingNode(HashAlgorithm hashAlgorithm, PendingNodeBranch node) { + FinalizedBranch left = node.getLeft().finalize(hashAlgorithm); + FinalizedBranch right = node.getRight().finalize(hashAlgorithm); + + BigInteger value = left.getValue().add(right.getValue()); + if (value.compareTo(FinalizedNodeBranch.SUM_LIMIT) >= 0) { + throw new ArithmeticException("RSMST internal sum overflow."); + } + + DataHash hash = new DataHasher(hashAlgorithm) + .update(new byte[]{0x11, (byte) node.getDepth()}) + .update(left.getHash().getData()) + .update(BigIntegerConverter.encode(left.getValue(), 32)) + .update(right.getHash().getData()) + .update(BigIntegerConverter.encode(right.getValue(), 32)) + .digest(); + + return new FinalizedNodeBranch(node.getPath(), node.getDepth(), left, right, value, hash); + } + + @Override + public PendingNodeBranch withLeftBranch(Branch left) { + return new PendingNodeBranch(this.path, this.depth, left, this.right); + } + + @Override + public PendingNodeBranch withRightBranch(Branch right) { + return new PendingNodeBranch(this.path, this.depth, this.left, right); + } + + @Override + public byte[] getPath() { + return Arrays.copyOf(this.path, this.path.length); + } + + @Override + public int getDepth() { + return this.depth; + } + + @Override + public int calculateSplitDepth(byte[] key) { + return SparseMerkleTreePathUtils.commonPrefixLength(key, this.path, this.depth); + } + + @Override + public FinalizedBranch getLeft() { + return this.left; + } + + @Override + public FinalizedBranch getRight() { + return this.right; + } + + @Override + public BigInteger getValue() { + return this.value; + } + + @Override + public DataHash getHash() { + return this.hash; + } + + @Override + public FinalizedNodeBranch finalize(HashAlgorithm hashAlgorithm) { + return this; + } +} diff --git a/src/main/java/org/unicitylabs/sdk/smt/radixsum/LeafBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radixsum/LeafBranch.java new file mode 100644 index 00000000..a5a71faf --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/smt/radixsum/LeafBranch.java @@ -0,0 +1,30 @@ +package org.unicitylabs.sdk.smt.radixsum; + +import java.math.BigInteger; + +/** + * Leaf branch in a radix sparse Merkle sum tree. + */ +interface LeafBranch extends Branch { + + /** + * Get the 32-byte leaf key. + * + * @return key + */ + byte[] getKey(); + + /** + * Get the 32-byte leaf data. + * + * @return data + */ + byte[] getData(); + + /** + * Get the leaf amount. + * + * @return amount + */ + BigInteger getValue(); +} diff --git a/src/main/java/org/unicitylabs/sdk/smt/radixsum/NodeBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radixsum/NodeBranch.java new file mode 100644 index 00000000..7c10919b --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/smt/radixsum/NodeBranch.java @@ -0,0 +1,45 @@ +package org.unicitylabs.sdk.smt.radixsum; + +/** + * Node branch in a radix sparse Merkle sum tree. + */ +interface NodeBranch extends Branch { + + /** + * Get the node's committed region: its {@code depth}-bit key prefix with the suffix zeroed. + * + * @return region + */ + byte[] getPath(); + + /** + * Get left branch. + * + * @return left branch + */ + Branch getLeft(); + + /** + * Get right branch. + * + * @return right branch + */ + Branch getRight(); + + /** + * Derive a pending node with {@code left} as its left child, reusing this node's committed region. + * + * @param left replacement left child + * @return new pending node + */ + PendingNodeBranch withLeftBranch(Branch left); + + /** + * Derive a pending node with {@code right} as its right child, reusing this node's committed + * region. + * + * @param right replacement right child + * @return new pending node + */ + PendingNodeBranch withRightBranch(Branch right); +} diff --git a/src/main/java/org/unicitylabs/sdk/smt/radixsum/PendingLeafBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radixsum/PendingLeafBranch.java new file mode 100644 index 00000000..fb618c93 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/smt/radixsum/PendingLeafBranch.java @@ -0,0 +1,54 @@ +package org.unicitylabs.sdk.smt.radixsum; + +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; + +import java.math.BigInteger; +import java.util.Arrays; + +/** + * Pending leaf in a radix sparse Merkle sum tree, awaiting hashing. + */ +class PendingLeafBranch implements LeafBranch { + private final byte[] key; + private final byte[] data; + private final BigInteger value; + private final int depth; + + public PendingLeafBranch(byte[] key, byte[] data, BigInteger value) { + this.key = key; + this.data = data; + this.value = value; + this.depth = key.length * 8; + } + + @Override + public int getDepth() { + return this.depth; + } + + @Override + public int calculateSplitDepth(byte[] key) { + return SparseMerkleTreePathUtils.commonPrefixLength(key, this.key, this.depth); + } + + @Override + public byte[] getKey() { + return Arrays.copyOf(this.key, this.key.length); + } + + @Override + public byte[] getData() { + return Arrays.copyOf(this.data, this.data.length); + } + + @Override + public BigInteger getValue() { + return this.value; + } + + @Override + public FinalizedLeafBranch finalize(HashAlgorithm hashAlgorithm) { + return FinalizedLeafBranch.fromPendingLeaf(hashAlgorithm, this); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/smt/radixsum/PendingNodeBranch.java b/src/main/java/org/unicitylabs/sdk/smt/radixsum/PendingNodeBranch.java new file mode 100644 index 00000000..c0c1c268 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/smt/radixsum/PendingNodeBranch.java @@ -0,0 +1,63 @@ +package org.unicitylabs.sdk.smt.radixsum; + +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; + +import java.util.Arrays; + +/** + * Pending interior node in a radix sparse Merkle sum tree, awaiting hashing. + */ +class PendingNodeBranch implements NodeBranch { + private final byte[] path; + private final int depth; + private final Branch left; + private final Branch right; + + public PendingNodeBranch(byte[] path, int depth, Branch left, Branch right) { + this.path = path; + this.depth = depth; + this.left = left; + this.right = right; + } + + @Override + public byte[] getPath() { + return Arrays.copyOf(this.path, this.path.length); + } + + @Override + public int getDepth() { + return this.depth; + } + + @Override + public int calculateSplitDepth(byte[] key) { + return SparseMerkleTreePathUtils.commonPrefixLength(key, this.path, this.depth); + } + + @Override + public Branch getLeft() { + return this.left; + } + + @Override + public Branch getRight() { + return this.right; + } + + @Override + public PendingNodeBranch withLeftBranch(Branch left) { + return new PendingNodeBranch(this.path, this.depth, left, this.right); + } + + @Override + public PendingNodeBranch withRightBranch(Branch right) { + return new PendingNodeBranch(this.path, this.depth, this.left, right); + } + + @Override + public FinalizedNodeBranch finalize(HashAlgorithm hashAlgorithm) { + return FinalizedNodeBranch.fromPendingNode(hashAlgorithm, this); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/smt/radixsum/SparseMerkleSumTree.java b/src/main/java/org/unicitylabs/sdk/smt/radixsum/SparseMerkleSumTree.java new file mode 100644 index 00000000..aad5d509 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/smt/radixsum/SparseMerkleSumTree.java @@ -0,0 +1,126 @@ +package org.unicitylabs.sdk.smt.radixsum; + +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.smt.LeafExistsException; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Objects; + +/** + * Radix sparse Merkle sum tree. It reuses the radix sparse Merkle tree structure (big-endian key + * routing, path-compressed binary trie, absolute bifurcation depths) and additionally commits a + * positive amount at every leaf and the accumulated sum at every internal node, so any inclusion + * proof also proves the leaf amount is part of the committed root total. + */ +public class SparseMerkleSumTree { + + private static final BigInteger VALUE_LIMIT = BigInteger.ONE.shiftLeft(256); + + private Branch left = null; + private Branch right = null; + + private final HashAlgorithm hashAlgorithm; + + /** + * Create radix sparse Merkle sum tree with given hash algorithm. + * + * @param hashAlgorithm hash algorithm + */ + public SparseMerkleSumTree(HashAlgorithm hashAlgorithm) { + this.hashAlgorithm = hashAlgorithm; + } + + /** + * Add a leaf to the tree. + * + * @param key 32-byte leaf key + * @param data 32-byte leaf data + * @param value leaf amount in the range {@code [1, 2^256)} + * @throws LeafExistsException if a leaf already exists for the key + * @throws IllegalArgumentException if the value is not a positive 256-bit integer, or the key + * or data is not 32 bytes + */ + public synchronized void addLeaf(byte[] key, byte[] data, BigInteger value) + throws LeafExistsException { + Objects.requireNonNull(key, "key cannot be null"); + Objects.requireNonNull(data, "data cannot be null"); + Objects.requireNonNull(value, "value cannot be null"); + + key = Arrays.copyOf(key, key.length); + data = Arrays.copyOf(data, data.length); + + if (value.signum() <= 0 || value.compareTo(SparseMerkleSumTree.VALUE_LIMIT) >= 0) { + throw new IllegalArgumentException("Value must be a positive 256-bit integer."); + } + + if (key.length != 32) { + throw new IllegalArgumentException("Key must be 32 bytes long."); + } + + if (data.length != 32) { + throw new IllegalArgumentException("Data must be 32 bytes long."); + } + + boolean isRight = SparseMerkleTreePathUtils.getBitAtDepth(key, 0) == 1; + Branch branch = isRight ? this.right : this.left; + Branch result = branch != null + ? SparseMerkleSumTree.buildTree(branch, key, data, value) + : new PendingLeafBranch(key, data, value); + + if (isRight) { + this.right = result; + } else { + this.left = result; + } + } + + /** + * Calculate root of the tree. + * + * @return root node + */ + public synchronized SparseMerkleSumTreeRootNode calculateRoot() { + FinalizedBranch left = this.left != null ? this.left.finalize(this.hashAlgorithm) : null; + FinalizedBranch right = this.right != null ? this.right.finalize(this.hashAlgorithm) : null; + this.left = left; + this.right = right; + + return SparseMerkleSumTreeRootNode.create(left, right, this.hashAlgorithm); + } + + private static Branch buildTree(Branch branch, byte[] key, byte[] data, BigInteger value) + throws LeafExistsException { + if (branch instanceof LeafBranch) { + int depth = branch.calculateSplitDepth(key); + if (depth == branch.getDepth()) { + throw new LeafExistsException(); + } + + boolean isRight = SparseMerkleTreePathUtils.getBitAtDepth(key, depth) == 1; + LeafBranch newBranch = new PendingLeafBranch(key, data, value); + return new PendingNodeBranch(SparseMerkleTreePathUtils.regionFromKey(key, depth), depth, + isRight ? branch : newBranch, isRight ? newBranch : branch); + } + + NodeBranch nodeBranch = (NodeBranch) branch; + int depth = branch.calculateSplitDepth(key); + boolean isRight = SparseMerkleTreePathUtils.getBitAtDepth(key, depth) == 1; + + // if node branch is split in the middle + if (depth < branch.getDepth()) { + LeafBranch newBranch = new PendingLeafBranch(key, data, value); + return new PendingNodeBranch(SparseMerkleTreePathUtils.regionFromKey(key, depth), depth, + isRight ? branch : newBranch, isRight ? newBranch : branch); + } + + if (isRight) { + return nodeBranch.withRightBranch( + SparseMerkleSumTree.buildTree(nodeBranch.getRight(), key, data, value)); + } + + return nodeBranch.withLeftBranch( + SparseMerkleSumTree.buildTree(nodeBranch.getLeft(), key, data, value)); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/smt/radixsum/SparseMerkleSumTreeRootNode.java b/src/main/java/org/unicitylabs/sdk/smt/radixsum/SparseMerkleSumTreeRootNode.java new file mode 100644 index 00000000..926065ba --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/smt/radixsum/SparseMerkleSumTreeRootNode.java @@ -0,0 +1,158 @@ +package org.unicitylabs.sdk.smt.radixsum; + +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.smt.SparseMerkleTreePathUtils; +import org.unicitylabs.sdk.util.HexConverter; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Radix sparse Merkle sum tree root node. If the tree holds a single leaf the root hash and sum + * are that leaf's; otherwise the root is the top internal node, which bifurcates at depth 0. The + * tree's branch nodes are an internal detail of this package; callers see only the root hash, the + * root sum and the sibling path. + */ +public class SparseMerkleSumTreeRootNode { + + private final FinalizedBranch left; + private final FinalizedBranch right; + private final BigInteger value; + private final DataHash hash; + + private SparseMerkleSumTreeRootNode(FinalizedBranch left, FinalizedBranch right, + BigInteger value, DataHash hash) { + this.left = left; + this.right = right; + this.value = value; + this.hash = hash; + } + + static SparseMerkleSumTreeRootNode create(FinalizedBranch left, FinalizedBranch right, + HashAlgorithm hashAlgorithm) { + if (left != null && right == null) { + return new SparseMerkleSumTreeRootNode(left, null, left.getValue(), left.getHash()); + } + if (left == null && right != null) { + return new SparseMerkleSumTreeRootNode(null, right, right.getValue(), right.getHash()); + } + + if (left != null) { + FinalizedNodeBranch node = new PendingNodeBranch(new byte[32], 0, left, right) + .finalize(hashAlgorithm); + return new SparseMerkleSumTreeRootNode(node.getLeft(), node.getRight(), node.getValue(), + node.getHash()); + } + + return new SparseMerkleSumTreeRootNode(null, null, BigInteger.ZERO, + new DataHash(hashAlgorithm, new byte[hashAlgorithm.getLength()])); + } + + /** + * Get the total sum committed by the tree. + * + * @return root sum + */ + public BigInteger getValue() { + return this.value; + } + + /** + * Get root hash. + * + * @return root hash + */ + public DataHash getHash() { + return this.hash; + } + + /** + * Enumerate the sibling entries on the path from the leaf with the given key up to the root, + * ordered from the leaf up to the root (strictly decreasing depth). + * + * @param key 32-byte leaf key + * @return sibling entries from the leaf up to the root + * @throws IllegalArgumentException if the key is not present in the tree + */ + public List getPath(byte[] key) { + Objects.requireNonNull(key, "key cannot be null"); + + List siblings = new ArrayList<>(); + boolean isRight = SparseMerkleTreePathUtils.getBitAtDepth(key, 0) == 1; + FinalizedBranch sibling = isRight ? this.left : this.right; + FinalizedBranch node = isRight ? this.right : this.left; + if (sibling != null) { + siblings.add(new Sibling(0, sibling.getHash(), sibling.getValue())); + } + + while (node instanceof FinalizedNodeBranch) { + FinalizedNodeBranch branch = (FinalizedNodeBranch) node; + int depth = branch.getDepth(); + isRight = SparseMerkleTreePathUtils.getBitAtDepth(key, depth) == 1; + sibling = isRight ? branch.getLeft() : branch.getRight(); + node = isRight ? branch.getRight() : branch.getLeft(); + if (sibling != null) { + siblings.add(new Sibling(depth, sibling.getHash(), sibling.getValue())); + } + } + + if (!(node instanceof FinalizedLeafBranch)) { + throw new IllegalArgumentException( + "Could not construct split allocation proof: invalid path."); + } + if (!Arrays.equals(((FinalizedLeafBranch) node).getKey(), key)) { + throw new IllegalArgumentException( + String.format("Leaf not found for key: %s", HexConverter.encode(key))); + } + + Collections.reverse(siblings); + return siblings; + } + + /** + * One sibling entry of a radix sparse Merkle sum tree inclusion path. + */ + public static final class Sibling { + private final int depth; + private final DataHash hash; + private final BigInteger value; + + private Sibling(int depth, DataHash hash, BigInteger value) { + this.depth = depth; + this.hash = hash; + this.value = value; + } + + /** + * Get the bifurcation depth at which this sibling hangs. + * + * @return depth + */ + public int getDepth() { + return this.depth; + } + + /** + * Get the sibling subtree hash. + * + * @return hash + */ + public DataHash getHash() { + return this.hash; + } + + /** + * Get the sum committed by the sibling subtree. + * + * @return sum + */ + public BigInteger getValue() { + return this.value; + } + } +} diff --git a/src/main/java/org/unicitylabs/sdk/smt/sum/Branch.java b/src/main/java/org/unicitylabs/sdk/smt/sum/Branch.java deleted file mode 100644 index ad1c518e..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/sum/Branch.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.unicitylabs.sdk.smt.sum; - -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; - -import java.math.BigInteger; - -/** - * Branch in a sparse merkle sum tree. - */ -interface Branch { - - /** - * Get path of the branch. - * - * @return path - */ - BigInteger getPath(); - - /** - * Finalize the branch by computing its hash. - * - * @param hashAlgorithm hash algorithm to use - * @return finalized branch - */ - FinalizedBranch finalize(HashAlgorithm hashAlgorithm); -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/sum/FinalizedLeafBranch.java b/src/main/java/org/unicitylabs/sdk/smt/sum/FinalizedLeafBranch.java deleted file mode 100644 index 29a43a06..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/sum/FinalizedLeafBranch.java +++ /dev/null @@ -1,91 +0,0 @@ -package org.unicitylabs.sdk.smt.sum; - -import org.unicitylabs.sdk.crypto.hash.DataHash; -import org.unicitylabs.sdk.crypto.hash.DataHasher; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.smt.sum.SparseMerkleSumTree.LeafValue; -import org.unicitylabs.sdk.util.BigIntegerConverter; - -import java.math.BigInteger; -import java.util.Objects; - -/** - * Finalized leaf branch in a sparse merkle sum tree. - */ -class FinalizedLeafBranch implements LeafBranch, FinalizedBranch { - - private final BigInteger path; - private final LeafValue value; - private final DataHash hash; - - private FinalizedLeafBranch(BigInteger path, LeafValue value, DataHash hash) { - this.path = path; - this.value = value; - this.hash = hash; - } - - /** - * Create a finalized leaf branch. - * - * @param path path of the branch - * @param value value stored in the leaf - * @param hashAlgorithm hash algorithm to use - * @return finalized leaf branch - */ - public static FinalizedLeafBranch create( - BigInteger path, - LeafValue value, - HashAlgorithm hashAlgorithm - ) { - DataHash hash = new DataHasher(hashAlgorithm) - .update( - CborSerializer.encodeArray( - CborSerializer.encodeByteString(BigIntegerConverter.encode(path)), - CborSerializer.encodeByteString(value.getValue()), - CborSerializer.encodeByteString(BigIntegerConverter.encode(value.getCounter())) - ) - ) - .digest(); - return new FinalizedLeafBranch(path, value, hash); - } - - @Override - public BigInteger getPath() { - return this.path; - } - - @Override - public LeafValue getValue() { - return this.value; - } - - @Override - public BigInteger getCounter() { - return this.value.getCounter(); - } - - @Override - public DataHash getHash() { - return this.hash; - } - - @Override - public FinalizedLeafBranch finalize(HashAlgorithm hashAlgorithm) { - return this; // Already finalized - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof FinalizedLeafBranch)) { - return false; - } - FinalizedLeafBranch that = (FinalizedLeafBranch) o; - return Objects.equals(this.path, that.path) && Objects.equals(this.value, that.value); - } - - @Override - public int hashCode() { - return Objects.hash(this.path, this.value); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/sum/FinalizedNodeBranch.java b/src/main/java/org/unicitylabs/sdk/smt/sum/FinalizedNodeBranch.java deleted file mode 100644 index da42dcde..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/sum/FinalizedNodeBranch.java +++ /dev/null @@ -1,118 +0,0 @@ -package org.unicitylabs.sdk.smt.sum; - -import org.unicitylabs.sdk.crypto.hash.DataHash; -import org.unicitylabs.sdk.crypto.hash.DataHasher; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.util.BigIntegerConverter; - -import java.math.BigInteger; -import java.util.Objects; - -/** - * Finalized node branch in a sparse merkle sum tree. - */ -class FinalizedNodeBranch implements NodeBranch, FinalizedBranch { - - private final BigInteger path; - private final FinalizedBranch left; - private final FinalizedBranch right; - private final BigInteger counter; - private final DataHash hash; - - private FinalizedNodeBranch( - BigInteger path, - FinalizedBranch left, - FinalizedBranch right, - BigInteger counter, - DataHash hash - ) { - this.path = path; - this.left = left; - this.right = right; - this.counter = counter; - this.hash = hash; - } - - /** - * Create a finalized node branch. - * - * @param path path of the branch - * @param left left branch - * @param right right branch - * @param hashAlgorithm hash algorithm to use - * @return finalized node branch - */ - public static FinalizedNodeBranch create( - BigInteger path, - FinalizedBranch left, - FinalizedBranch right, - HashAlgorithm hashAlgorithm - ) { - byte[] leftHash = left == null ? null : left.getHash().getData(); - byte[] rightHash = right == null ? null : right.getHash().getData(); - BigInteger leftCounter = left == null ? BigInteger.ZERO : left.getCounter(); - BigInteger rightCounter = right == null ? BigInteger.ZERO : right.getCounter(); - - DataHash hash = new DataHasher(hashAlgorithm) - .update( - CborSerializer.encodeArray( - CborSerializer.encodeByteString(BigIntegerConverter.encode(path)), - CborSerializer.encodeNullable(leftHash, CborSerializer::encodeByteString), - CborSerializer.encodeByteString(BigIntegerConverter.encode(leftCounter)), - CborSerializer.encodeNullable(rightHash, CborSerializer::encodeByteString), - CborSerializer.encodeByteString(BigIntegerConverter.encode(rightCounter)) - ) - ) - .digest(); - - BigInteger counter = leftCounter.add(rightCounter); - - return new FinalizedNodeBranch(path, left, right, counter, hash); - } - - @Override - public BigInteger getPath() { - return this.path; - } - - @Override - public FinalizedBranch getLeft() { - return this.left; - } - - @Override - public FinalizedBranch getRight() { - return this.right; - } - - @Override - public BigInteger getCounter() { - return this.counter; - } - - @Override - public DataHash getHash() { - return this.hash; - } - - @Override - public FinalizedNodeBranch finalize(HashAlgorithm hashAlgorithm) { - return this; // Already finalized - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof FinalizedNodeBranch)) { - return false; - } - FinalizedNodeBranch that = (FinalizedNodeBranch) o; - return Objects.equals(this.path, that.path) && Objects.equals(this.left, that.left) - && Objects.equals(this.right, that.right); - } - - @Override - public int hashCode() { - return Objects.hash(this.path, this.left, this.right); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/sum/LeafBranch.java b/src/main/java/org/unicitylabs/sdk/smt/sum/LeafBranch.java deleted file mode 100644 index 29e7d9a4..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/sum/LeafBranch.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.unicitylabs.sdk.smt.sum; - -import org.unicitylabs.sdk.smt.sum.SparseMerkleSumTree.LeafValue; - -/** - * Leaf branch in a sparse merkle sum tree. - */ -interface LeafBranch extends Branch { - - /** - * Get value stored in the leaf. - * - * @return value stored in the leaf - */ - LeafValue getValue(); -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/sum/NodeBranch.java b/src/main/java/org/unicitylabs/sdk/smt/sum/NodeBranch.java deleted file mode 100644 index 357a9683..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/sum/NodeBranch.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.unicitylabs.sdk.smt.sum; - -/** - * Node branch in sparse merkle sum tree. - */ -interface NodeBranch extends Branch { - - /** - * Get left branch. - * - * @return left branch - */ - Branch getLeft(); - - /** - * Get right branch. - * - * @return right branch - */ - Branch getRight(); -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/sum/PendingLeafBranch.java b/src/main/java/org/unicitylabs/sdk/smt/sum/PendingLeafBranch.java deleted file mode 100644 index f4f10c47..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/sum/PendingLeafBranch.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.unicitylabs.sdk.smt.sum; - -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.smt.sum.SparseMerkleSumTree.LeafValue; - -import java.math.BigInteger; -import java.util.Objects; - -/** - * Pending leaf branch in a sparse merkle sum tree. - */ -class PendingLeafBranch implements LeafBranch { - - private final BigInteger path; - private final LeafValue value; - - /** - * Create a pending leaf branch. - * - * @param path path of the branch - * @param value value stored in the leaf - */ - public PendingLeafBranch(BigInteger path, LeafValue value) { - this.path = path; - this.value = value; - } - - @Override - public BigInteger getPath() { - return this.path; - } - - @Override - public LeafValue getValue() { - return this.value; - } - - @Override - public FinalizedLeafBranch finalize(HashAlgorithm hashAlgorithm) { - return FinalizedLeafBranch.create(this.path, this.value, hashAlgorithm); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof PendingLeafBranch)) { - return false; - } - PendingLeafBranch that = (PendingLeafBranch) o; - return Objects.equals(this.path, that.path) && Objects.deepEquals(this.value, that.value); - } - - @Override - public int hashCode() { - return Objects.hash(this.path, this.value); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/sum/PendingNodeBranch.java b/src/main/java/org/unicitylabs/sdk/smt/sum/PendingNodeBranch.java deleted file mode 100644 index 891dca61..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/sum/PendingNodeBranch.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.unicitylabs.sdk.smt.sum; - -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; - -import java.math.BigInteger; -import java.util.Objects; - -/** - * Pending node branch in a sparse merkle sum tree. - */ -class PendingNodeBranch implements NodeBranch { - - private final BigInteger path; - private final Branch left; - private final Branch right; - - /** - * Create a pending node branch. - * - * @param path path of the branch - * @param left left branch - * @param right right branch - */ - public PendingNodeBranch(BigInteger path, Branch left, Branch right) { - this.path = path; - this.left = left; - this.right = right; - } - - @Override - public BigInteger getPath() { - return this.path; - } - - @Override - public Branch getLeft() { - return this.left; - } - - @Override - public Branch getRight() { - return this.right; - } - - @Override - public FinalizedNodeBranch finalize(HashAlgorithm hashAlgorithm) { - return FinalizedNodeBranch.create(this.path, this.left.finalize(hashAlgorithm), - this.right.finalize(hashAlgorithm), hashAlgorithm); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof PendingNodeBranch)) { - return false; - } - PendingNodeBranch that = (PendingNodeBranch) o; - return Objects.equals(this.path, that.path) && Objects.equals(this.left, that.left) - && Objects.equals(this.right, that.right); - } - - @Override - public int hashCode() { - return Objects.hash(this.path, this.left, this.right); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTree.java b/src/main/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTree.java deleted file mode 100644 index 467fb3aa..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTree.java +++ /dev/null @@ -1,185 +0,0 @@ -package org.unicitylabs.sdk.smt.sum; - -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.smt.BranchExistsException; -import org.unicitylabs.sdk.smt.CommonPath; -import org.unicitylabs.sdk.smt.LeafOutOfBoundsException; - -import java.math.BigInteger; -import java.util.Arrays; -import java.util.Objects; - -/** - * Sparse Merkle Sum Tree implementation. - */ -public class SparseMerkleSumTree { - - private Branch left = null; - private Branch right = null; - - private final HashAlgorithm hashAlgorithm; - - /** - * Create a sparse merkle sum tree. - * - * @param hashAlgorithm hash algorithm to use - */ - public SparseMerkleSumTree(HashAlgorithm hashAlgorithm) { - this.hashAlgorithm = hashAlgorithm; - } - - /** - * Add a leaf to the tree. - * - * @param path path of the leaf (must be greater than 0) - * @param value value stored in the leaf - * @throws BranchExistsException if a branch already exists at the given path - * @throws LeafOutOfBoundsException if a leaf already exists at the given path - * @throws IllegalArgumentException if the path is less than or equal to 0 or if the counter is negative - * @throws NullPointerException if the path or value is null - */ - public synchronized void addLeaf(BigInteger path, LeafValue value) - throws BranchExistsException, LeafOutOfBoundsException { - Objects.requireNonNull(path, "Path cannot be null"); - Objects.requireNonNull(value, "Value cannot be null"); - - if (path.compareTo(BigInteger.ONE) < 0) { - throw new IllegalArgumentException("Path must be greater than 0"); - } - - if (value.getCounter().signum() < 0) { - throw new IllegalArgumentException("Counter must be an unsigned BigInteger."); - } - - boolean isRight = path.testBit(0); - Branch branch = isRight ? this.right : this.left; - Branch result = branch != null - ? SparseMerkleSumTree.buildTree(branch, path, value) - : new PendingLeafBranch(path, value); - - if (isRight) { - this.right = result; - } else { - this.left = result; - } - } - - /** - * Calculate the root of the tree and its state. - * - * @return root node of the tree - */ - public synchronized SparseMerkleSumTreeRootNode calculateRoot() { - FinalizedBranch left = this.left != null ? this.left.finalize(this.hashAlgorithm) : null; - FinalizedBranch right = this.right != null ? this.right.finalize(this.hashAlgorithm) : null; - this.left = left; - this.right = right; - - return SparseMerkleSumTreeRootNode.create(left, right, this.hashAlgorithm); - } - - private static Branch buildTree(Branch branch, BigInteger remainingPath, LeafValue value) - throws BranchExistsException, LeafOutOfBoundsException { - CommonPath commonPath = CommonPath.create(remainingPath, branch.getPath()); - boolean isRight = remainingPath.shiftRight(commonPath.getLength()).testBit(0); - - if (commonPath.getPath().equals(remainingPath)) { - throw new BranchExistsException(); - } - - if (branch instanceof LeafBranch) { - if (commonPath.getPath().equals(branch.getPath())) { - throw new LeafOutOfBoundsException(); - } - - LeafBranch leafBranch = (LeafBranch) branch; - - LeafBranch oldBranch = new PendingLeafBranch( - branch.getPath().shiftRight(commonPath.getLength()), leafBranch.getValue()); - LeafBranch newBranch = new PendingLeafBranch(remainingPath.shiftRight(commonPath.getLength()), - value); - return new PendingNodeBranch(commonPath.getPath(), isRight ? oldBranch : newBranch, - isRight ? newBranch : oldBranch); - } - - NodeBranch nodeBranch = (NodeBranch) branch; - - // if node branch is split in the middle - if (commonPath.getPath().compareTo(branch.getPath()) < 0) { - LeafBranch newBranch = new PendingLeafBranch(remainingPath.shiftRight(commonPath.getLength()), - value); - NodeBranch oldBranch = new PendingNodeBranch( - branch.getPath().shiftRight(commonPath.getLength()), nodeBranch.getLeft(), - nodeBranch.getRight()); - return new PendingNodeBranch(commonPath.getPath(), isRight ? oldBranch : newBranch, - isRight ? newBranch : oldBranch); - } - - if (isRight) { - return new PendingNodeBranch(nodeBranch.getPath(), nodeBranch.getLeft(), - SparseMerkleSumTree.buildTree(nodeBranch.getRight(), - remainingPath.shiftRight(commonPath.getLength()), value)); - } - - return new PendingNodeBranch(nodeBranch.getPath(), - SparseMerkleSumTree.buildTree(nodeBranch.getLeft(), - remainingPath.shiftRight(commonPath.getLength()), value), nodeBranch.getRight()); - } - - /** - * Value stored in a leaf of the sparse merkle sum tree. - */ - public static class LeafValue { - - private final byte[] value; - private final BigInteger counter; - - /** - * Create a leaf value. - * - * @param value byte array value - * @param counter unsigned counter - * @throws NullPointerException if the value or counter is null - */ - public LeafValue(byte[] value, BigInteger counter) { - Objects.requireNonNull(value, "Value cannot be null"); - Objects.requireNonNull(counter, "Counter cannot be null"); - - this.value = Arrays.copyOf(value, value.length); - this.counter = counter; - } - - /** - * Get a copy of leaf byte value. - * - * @return bytes - */ - public byte[] getValue() { - return Arrays.copyOf(this.value, this.value.length); - } - - /** - * Get the counter. - * - * @return counter - */ - public BigInteger getCounter() { - return this.counter; - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof LeafValue)) { - return false; - } - LeafValue that = (LeafValue) o; - return Arrays.equals(this.value, that.value) && Objects.equals(this.counter, that.counter); - } - - @Override - public int hashCode() { - return Objects.hash(Arrays.hashCode(this.value), this.counter); - } - } -} - diff --git a/src/main/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTreePath.java b/src/main/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTreePath.java deleted file mode 100644 index da2e8679..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTreePath.java +++ /dev/null @@ -1,179 +0,0 @@ -package org.unicitylabs.sdk.smt.sum; - -import org.unicitylabs.sdk.crypto.hash.DataHash; -import org.unicitylabs.sdk.crypto.hash.DataHasher; -import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.smt.MerkleTreePathVerificationResult; -import org.unicitylabs.sdk.util.BigIntegerConverter; - -import java.math.BigInteger; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -/** - * Path in a sparse merkle sum tree. - */ -public class SparseMerkleSumTreePath { - - private final DataHash rootHash; - private final List steps; - - SparseMerkleSumTreePath( - DataHash rootHash, - List steps - ) { - Objects.requireNonNull(rootHash, "root cannot be null"); - Objects.requireNonNull(steps, "steps cannot be null"); - - this.rootHash = rootHash; - this.steps = List.copyOf(steps); - } - - /** - * Get root hash. - * - * @return root hash - */ - public DataHash getRootHash() { - return this.rootHash; - } - - /** - * Get steps of the path from leaf to the root. - * - * @return steps - */ - public List getSteps() { - return this.steps; - } - - /** - * Verify the path against the given state ID. - * - * @param stateId state ID to verify against - * @return result of the verification - */ - public MerkleTreePathVerificationResult verify(BigInteger stateId) { - if (this.steps.isEmpty()) { - return new MerkleTreePathVerificationResult(false, false); - } - - SparseMerkleSumTreePathStep step = this.steps.get(0); - byte[] currentData; - BigInteger currentPath = step.getPath(); - BigInteger currentSum = step.getValue(); - if (step.getPath().compareTo(BigInteger.ONE) > 0) { - DataHash hash = new DataHasher(this.rootHash.getAlgorithm()) - .update( - CborSerializer.encodeArray( - CborSerializer.encodeByteString(BigIntegerConverter.encode(step.getPath())), - CborSerializer.encodeNullable( - step.getData().orElse(null), - CborSerializer::encodeByteString - ), - CborSerializer.encodeByteString(BigIntegerConverter.encode(step.getValue())) - ) - ) - .digest(); - - currentData = hash.getData(); - } else { - currentPath = BigInteger.ONE; - currentData = step.getData().orElse(null); - } - - SparseMerkleSumTreePathStep previousStep = step; - for (int i = 1; i < this.steps.size(); i++) { - step = this.steps.get(i); - boolean isRight = previousStep.getPath().testBit(0); - - byte[] leftHash = isRight ? step.getData().orElse(null) : currentData; - byte[] rightHash = isRight ? currentData : step.getData().orElse(null); - BigInteger leftCounter = isRight ? step.getValue() : currentSum; - BigInteger rightCounter = isRight ? currentSum : step.getValue(); - - DataHash hash = new DataHasher(this.rootHash.getAlgorithm()) - .update( - CborSerializer.encodeArray( - CborSerializer.encodeByteString(BigIntegerConverter.encode(step.getPath())), - CborSerializer.encodeNullable(leftHash, CborSerializer::encodeByteString), - CborSerializer.encodeByteString(BigIntegerConverter.encode(leftCounter)), - CborSerializer.encodeNullable(rightHash, CborSerializer::encodeByteString), - CborSerializer.encodeByteString(BigIntegerConverter.encode(rightCounter)) - ) - ) - .digest(); - - currentData = hash.getData(); - - int length = step.getPath().bitLength() - 1; - if (length < 0) { - return new MerkleTreePathVerificationResult(false, false); - } - currentPath = currentPath.shiftLeft(length) - .or(step.getPath().and(BigInteger.ONE.shiftLeft(length).subtract(BigInteger.ONE))); - currentSum = currentSum.add(step.getValue()); - previousStep = step; - } - - boolean pathValid = currentData != null - && this.rootHash.equals(new DataHash(this.rootHash.getAlgorithm(), currentData)); - boolean pathIncluded = currentPath.compareTo(stateId) == 0; - - return new MerkleTreePathVerificationResult(pathValid, pathIncluded); - } - - /** - * Create path from CBOR bytes. - * - * @param bytes CBOR bytes - * @return path - */ - public static SparseMerkleSumTreePath fromCbor(byte[] bytes) { - List data = CborDeserializer.decodeArray(bytes, 2); - - return new SparseMerkleSumTreePath( - DataHash.fromCbor(data.get(0)), - CborDeserializer.decodeArray(data.get(1)).stream() - .map(SparseMerkleSumTreePathStep::fromCbor) - .collect(Collectors.toList()) - ); - } - - /** - * Serialize path to CBOR bytes. - * - * @return CBOR bytes - */ - public byte[] toCbor() { - return CborSerializer.encodeArray( - this.rootHash.toCbor(), - CborSerializer.encodeArray( - this.steps.stream() - .map(SparseMerkleSumTreePathStep::toCbor) - .toArray(byte[][]::new) - ) - ); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof SparseMerkleSumTreePath)) { - return false; - } - SparseMerkleSumTreePath that = (SparseMerkleSumTreePath) o; - return Objects.equals(this.rootHash, that.rootHash) && Objects.equals(this.steps, that.steps); - } - - @Override - public int hashCode() { - return Objects.hash(this.rootHash, this.steps); - } - - @Override - public String toString() { - return String.format("MerkleTreePath{rootHash=%s, steps=%s}", this.rootHash, this.steps); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTreePathStep.java b/src/main/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTreePathStep.java deleted file mode 100644 index 532fce80..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTreePathStep.java +++ /dev/null @@ -1,115 +0,0 @@ -package org.unicitylabs.sdk.smt.sum; - -import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.util.BigIntegerConverter; -import org.unicitylabs.sdk.util.HexConverter; - -import java.math.BigInteger; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -/** - * Step in a sparse merkle sum tree path. - */ -public class SparseMerkleSumTreePathStep { - - private final BigInteger path; - private final byte[] data; - private final BigInteger value; - - SparseMerkleSumTreePathStep( - BigInteger path, - byte[] data, - BigInteger value - ) { - Objects.requireNonNull(path, "path cannot be null"); - Objects.requireNonNull(value, "value cannot be null"); - - this.path = path; - this.data = data; - this.value = value; - } - - /** - * Get path of the step. - * - * @return path - */ - public BigInteger getPath() { - return this.path; - } - - /** - * Get data of step. - * - * @return data - */ - public Optional getData() { - return Optional.ofNullable(this.data); - } - - /** - * Get value of step. - * - * @return value - */ - public BigInteger getValue() { - return this.value; - } - - /** - * Deserialize a step from CBOR bytes. - * - * @param bytes CBOR bytes - * @return step - */ - public static SparseMerkleSumTreePathStep fromCbor(byte[] bytes) { - List data = CborDeserializer.decodeArray(bytes, 3); - - return new SparseMerkleSumTreePathStep( - BigIntegerConverter.decode(CborDeserializer.decodeByteString(data.get(0))), - CborDeserializer.decodeNullable(data.get(1), CborDeserializer::decodeByteString), - BigIntegerConverter.decode(CborDeserializer.decodeByteString(data.get(2))) - ); - } - - /** - * Serialize step to CBOR bytes. - * - * @return CBOR bytes - */ - public byte[] toCbor() { - return CborSerializer.encodeArray( - CborSerializer.encodeByteString(BigIntegerConverter.encode(this.path)), - CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString), - CborSerializer.encodeByteString(BigIntegerConverter.encode(this.value)) - ); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof SparseMerkleSumTreePathStep)) { - return false; - } - SparseMerkleSumTreePathStep that = (SparseMerkleSumTreePathStep) o; - return Objects.equals(this.path, that.path) && Arrays.equals(this.data, that.data) - && Objects.equals(this.value, that.value); - } - - @Override - public int hashCode() { - return Objects.hash(this.path, Arrays.hashCode(this.data), this.value); - } - - @Override - public String toString() { - return String.format("MerkleTreePathStep{path=%s, data=%s, value=%s}", - this.path.toString(2), - this.data == null ? null : HexConverter.encode(this.data), - this.value - ); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTreeRootNode.java b/src/main/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTreeRootNode.java deleted file mode 100644 index 9e7d7101..00000000 --- a/src/main/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTreeRootNode.java +++ /dev/null @@ -1,148 +0,0 @@ -package org.unicitylabs.sdk.smt.sum; - -import org.unicitylabs.sdk.crypto.hash.DataHash; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.smt.CommonPath; - -import java.math.BigInteger; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -/** - * Sparse Merkle Sum Tree root node. - */ -public class SparseMerkleSumTreeRootNode { - - private final FinalizedNodeBranch root; - - private SparseMerkleSumTreeRootNode(FinalizedNodeBranch root) { - this.root = root; - } - - static SparseMerkleSumTreeRootNode create( - FinalizedBranch left, - FinalizedBranch right, - HashAlgorithm hashAlgorithm - ) { - return new SparseMerkleSumTreeRootNode( - FinalizedNodeBranch.create(BigInteger.ONE, left, right, hashAlgorithm) - ); - } - - /** - * Get root hash. - * - * @return root hash - */ - public DataHash getRootHash() { - return this.root.getHash(); - } - - /** - * Get root value. - * - * @return root value - */ - public BigInteger getValue() { - return this.root.getCounter(); - } - - /** - * Get merkle sum tree path for requested path. - * - * @param path path - * @return merkle tree path - */ - public SparseMerkleSumTreePath getPath(BigInteger path) { - return new SparseMerkleSumTreePath( - this.root.getHash(), - SparseMerkleSumTreeRootNode.generatePath(path, this.root) - ); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof SparseMerkleSumTreeRootNode)) { - return false; - } - SparseMerkleSumTreeRootNode that = (SparseMerkleSumTreeRootNode) o; - return Objects.equals(this.root, that.root); - } - - @Override - public int hashCode() { - return Objects.hash(this.root); - } - - private static List generatePath( - BigInteger remainingPath, - FinalizedBranch parent - ) { - if (parent instanceof LeafBranch) { - LeafBranch leaf = (LeafBranch) parent; - return List.of(new SparseMerkleSumTreePathStep( - leaf.getPath(), - leaf.getValue().getValue(), - leaf.getValue().getCounter() - )); - } - - FinalizedNodeBranch node = (FinalizedNodeBranch) parent; - CommonPath commonPath = CommonPath.create(remainingPath, parent.getPath()); - remainingPath = remainingPath.shiftRight(commonPath.getLength()); - - if (commonPath.getPath().compareTo(parent.getPath()) != 0 - || remainingPath.compareTo(BigInteger.ONE) == 0) { - return List.of( - new SparseMerkleSumTreePathStep( - BigInteger.ZERO, - node.getLeft() == null - ? null - : node.getLeft().getHash().getData(), - node.getLeft() == null - ? BigInteger.ZERO - : node.getLeft().getCounter() - ), - new SparseMerkleSumTreePathStep( - node.getPath(), - node.getRight() == null - ? null - : node.getRight().getHash().getData(), - node.getRight() == null - ? BigInteger.ZERO - : node.getRight().getCounter() - ) - ); - } - - boolean isRight = remainingPath.testBit(0); - FinalizedBranch branch = isRight ? node.getRight() : node.getLeft(); - FinalizedBranch siblingBranch = isRight ? node.getLeft() : node.getRight(); - - SparseMerkleSumTreePathStep step = new SparseMerkleSumTreePathStep( - node.getPath(), - siblingBranch == null ? null : siblingBranch.getHash().getData(), - siblingBranch == null ? BigInteger.ZERO : siblingBranch.getCounter() - ); - - if (branch == null) { - return List.of( - new SparseMerkleSumTreePathStep( - isRight ? BigInteger.ONE : BigInteger.ZERO, - null, - BigInteger.ZERO - ), - step - ); - } - - List list = new ArrayList<>( - SparseMerkleSumTreeRootNode.generatePath(remainingPath, branch) - ); - - list.add(step); - - return List.copyOf(list); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java index a644018b..b7ccf95f 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedMintTransaction.java @@ -91,7 +91,7 @@ public Optional getJustification() { } @Override - public byte[] getStateMask() { + public StateMask getStateMask() { return this.transaction.getStateMask(); } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java index 16beb0e5..70eedb0d 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/CertifiedTransferTransaction.java @@ -53,7 +53,7 @@ public DataHash getSourceStateHash() { } @Override - public byte[] getStateMask() { + public StateMask getStateMask() { return this.transaction.getStateMask(); } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java index b707d3a4..01608bfd 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/MintTransaction.java @@ -136,8 +136,8 @@ public Optional getData() { } @Override - public byte[] getStateMask() { - return this.tokenId.getBytes(); + public StateMask getStateMask() { + return StateMask.fromBytes(this.tokenId.getBytes()); } /** @@ -357,7 +357,7 @@ public DataHash calculateStateHash() { .update( CborSerializer.encodeArray( CborSerializer.encodeByteString(this.sourceStateHash.getImprint()), - CborSerializer.encodeByteString(this.getStateMask()) + this.getStateMask().toCbor() ) ) .digest(); diff --git a/src/main/java/org/unicitylabs/sdk/transaction/StateMask.java b/src/main/java/org/unicitylabs/sdk/transaction/StateMask.java new file mode 100644 index 00000000..5b662502 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/transaction/StateMask.java @@ -0,0 +1,109 @@ +package org.unicitylabs.sdk.transaction; + +import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; +import org.unicitylabs.sdk.serializer.cbor.CborSerializer; +import org.unicitylabs.sdk.util.HexConverter; + +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Objects; + +/** + * Random value mixed into a transfer's next state hash. Its randomness makes the next state + * identifier unpredictable, preventing the Unicity Service from linking consecutive states of the + * same token. Per the yellowpaper the mask is variable length ({@code x <- {0,1}^l}) and MUST be + * sampled with at least 128 bits of min-entropy, so the minter chooses the length within + * {@code [{@link #MIN_LENGTH}, {@link #MAX_LENGTH}]} bytes; the upper bound keeps an untrusted token + * blob from carrying an arbitrarily large mask. {@link #generate()} samples {@link #LENGTH} bytes. + */ +public class StateMask { + + public static final int LENGTH = 32; + public static final int MAX_LENGTH = 64; + public static final int MIN_LENGTH = 16; + + private static final SecureRandom RANDOM = new SecureRandom(); + private final byte[] bytes; + + private StateMask(byte[] bytes) { + this.bytes = bytes; + } + + /** + * Wrap an existing state mask. The mask is variable length but must carry at least 128 bits of + * min-entropy and stay within the upper bound, so it must be between {@link StateMask#MIN_LENGTH} + * and {@link StateMask#MAX_LENGTH} bytes. + * + * @param bytes state mask bytes; must be 16 to 64 bytes + * + * @return state mask + */ + public static StateMask fromBytes(byte[] bytes) { + Objects.requireNonNull(bytes, "State mask cannot be null"); + if (bytes.length < StateMask.MIN_LENGTH || bytes.length > StateMask.MAX_LENGTH) { + throw new IllegalArgumentException( + "StateMask must be between " + StateMask.MIN_LENGTH + " and " + StateMask.MAX_LENGTH + + " bytes, got " + bytes.length + "."); + } + return new StateMask(Arrays.copyOf(bytes, bytes.length)); + } + + /** + * Deserialize a state mask from CBOR bytes. + * + * @param bytes CBOR encoded state mask bytes + * + * @return state mask + */ + public static StateMask fromCbor(byte[] bytes) { + return StateMask.fromBytes(CborDeserializer.decodeByteString(bytes)); + } + + /** + * Generate a fresh random 32-byte state mask. + * + * @return state mask + */ + public static StateMask generate() { + byte[] bytes = new byte[StateMask.LENGTH]; + RANDOM.nextBytes(bytes); + return new StateMask(bytes); + } + + /** + * Get state mask bytes. + * + * @return state mask bytes + */ + public byte[] getBytes() { + return Arrays.copyOf(this.bytes, this.bytes.length); + } + + /** + * Serialize state mask to CBOR bytes. + * + * @return CBOR bytes + */ + public byte[] toCbor() { + return CborSerializer.encodeByteString(this.bytes); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof StateMask)) { + return false; + } + StateMask stateMask = (StateMask) o; + return Arrays.equals(this.bytes, stateMask.bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(this.bytes); + } + + @Override + public String toString() { + return String.format("StateMask[%s]", HexConverter.encode(this.bytes)); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/transaction/Token.java b/src/main/java/org/unicitylabs/sdk/transaction/Token.java index 23b4cc0a..7ae82ac5 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/Token.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/Token.java @@ -1,17 +1,16 @@ package org.unicitylabs.sdk.transaction; -import org.unicitylabs.sdk.api.bft.RootTrustBase; -import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; import org.unicitylabs.sdk.serializer.cbor.CborSerializer; import org.unicitylabs.sdk.transaction.verification.CertifiedMintTransactionVerificationRule; import org.unicitylabs.sdk.transaction.verification.CertifiedTransferTransactionVerificationRule; -import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; +import org.unicitylabs.sdk.transaction.verification.VerificationContext; import org.unicitylabs.sdk.util.verification.VerificationException; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.util.verification.VerificationStatus; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -120,26 +119,20 @@ public static Token fromCbor(byte[] bytes) { /** * Creates a token from a certified genesis transaction and verifies it. * - * @param trustBase trust base used for certification checks - * @param predicateVerifier predicate verifier service - * @param mintJustificationVerifier mint justification verifier service * @param genesis certified mint transaction + * @param context shared verification context (trust base + registries) * @return verified token instance * @throws VerificationException if genesis verification fails */ public static Token mint( - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - MintJustificationVerifierService mintJustificationVerifier, - CertifiedMintTransaction genesis + CertifiedMintTransaction genesis, + VerificationContext context ) { - Objects.requireNonNull(trustBase, "trustBase cannot be null"); - Objects.requireNonNull(predicateVerifier, "predicateVerifier cannot be null"); - Objects.requireNonNull(mintJustificationVerifier, "mintJustificationVerifier cannot be null"); Objects.requireNonNull(genesis, "genesis cannot be null"); + Objects.requireNonNull(context, "context cannot be null"); Token token = new Token(genesis); - VerificationResult result = token.verify(trustBase, predicateVerifier, mintJustificationVerifier); + VerificationResult result = token.verify(context); if (result.getStatus() != VerificationStatus.OK) { throw new VerificationException("Invalid token genesis", result); } @@ -150,25 +143,21 @@ public static Token mint( /** * Returns a new token instance with an additional verified transfer transaction. * - * @param trustBase trust base used for certification checks - * @param predicateVerifier predicate verifier service * @param transaction certified transfer transaction to append + * @param context shared verification context (trust base + registries) * @return new token instance with appended transfer * @throws VerificationException if transfer verification fails */ public Token transfer( - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - CertifiedTransferTransaction transaction + CertifiedTransferTransaction transaction, + VerificationContext context ) { - Objects.requireNonNull(trustBase, "trustBase cannot be null"); - Objects.requireNonNull(predicateVerifier, "predicateVerifier cannot be null"); Objects.requireNonNull(transaction, "transaction cannot be null"); + Objects.requireNonNull(context, "context cannot be null"); VerificationResult result = CertifiedTransferTransactionVerificationRule.verify( - trustBase, - predicateVerifier, - transaction + transaction, + context ); if (result.getStatus() != VerificationStatus.OK) { throw new VerificationException("Invalid token transfer transaction", result); @@ -182,50 +171,59 @@ public Token transfer( /** * Verifies genesis and transfer transaction chain integrity. * - * @param trustBase trust base used for certification checks - * @param predicateVerifier predicate verifier service - * @param mintJustificationVerifier mint justification verifier service - * @return verification result with nested per-step verification details + *

Tokens embedded in mint justifications (for example, the burn token of a split) are + * verified iteratively with a worklist instead of recursion, so arbitrarily long provenance + * chains do not grow the call stack. + * + * @param context shared verification context (trust base + registries) + * @return verification result with a child result per verified token */ - public VerificationResult verify( - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - MintJustificationVerifierService mintJustificationVerifier - ) { - Objects.requireNonNull(trustBase, "trustBase cannot be null"); - Objects.requireNonNull(predicateVerifier, "predicateVerifier cannot be null"); - Objects.requireNonNull(mintJustificationVerifier, "mintJustificationVerifier cannot be null"); + public VerificationResult verify(VerificationContext context) { + Objects.requireNonNull(context, "context cannot be null"); - List> results = new ArrayList<>(); - VerificationResult result = CertifiedMintTransactionVerificationRule.verify( - trustBase, - predicateVerifier, - mintJustificationVerifier, - this.genesis - ); - results.add(result); - if (result.getStatus() != VerificationStatus.OK) { - return new VerificationResult<>("TokenVerification", VerificationStatus.FAIL, - "Genesis verification failed", results); - } + ArrayDeque pending = new ArrayDeque<>(); + pending.add(this); - List> transferResults = new ArrayList<>(); - for (int i = 0; i < this.transactions.size(); i++) { - CertifiedTransferTransaction transaction = this.transactions.get(i); - result = CertifiedTransferTransactionVerificationRule.verify(trustBase, predicateVerifier, transaction); - transferResults.add(result); + List> results = new ArrayList<>(); + for (int tokenIndex = 0; !pending.isEmpty(); tokenIndex++) { + Token token = pending.poll(); + String rule = String.format("Token[%s:%s]", tokenIndex, token.getId()); + + List> tokenResults = new ArrayList<>(); + VerificationResult result = CertifiedMintTransactionVerificationRule.verify( + token.genesis, + context, + pending::add + ); + tokenResults.add(result); if (result.getStatus() != VerificationStatus.OK) { - results.add( - new VerificationResult<>("TokenTransferVerification", VerificationStatus.FAIL, "", - transferResults) - ); - + results.add(new VerificationResult<>(rule, VerificationStatus.FAIL, + "Genesis verification failed", tokenResults)); return new VerificationResult<>("TokenVerification", VerificationStatus.FAIL, - String.format("Transaction[%s] verification failed", i), results); + String.format("%s verification failed", rule), results); + } + + List> transferResults = new ArrayList<>(); + for (int i = 0; i < token.transactions.size(); i++) { + CertifiedTransferTransaction transaction = token.transactions.get(i); + result = CertifiedTransferTransactionVerificationRule.verify(transaction, context); + transferResults.add(result); + if (result.getStatus() != VerificationStatus.OK) { + tokenResults.add( + new VerificationResult<>("TokenTransferVerification", VerificationStatus.FAIL, "", + transferResults) + ); + results.add(new VerificationResult<>(rule, VerificationStatus.FAIL, + String.format("Transaction[%s] verification failed", i), tokenResults)); + + return new VerificationResult<>("TokenVerification", VerificationStatus.FAIL, + String.format("%s verification failed", rule), results); + } } + tokenResults.add(new VerificationResult<>("TokenTransferVerification", VerificationStatus.OK, "", + transferResults)); + results.add(new VerificationResult<>(rule, VerificationStatus.OK, "", tokenResults)); } - results.add(new VerificationResult<>("TokenTransferVerification", VerificationStatus.OK, "", - transferResults)); return new VerificationResult<>("TokenVerification", VerificationStatus.OK, "", results); } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/TokenSalt.java b/src/main/java/org/unicitylabs/sdk/transaction/TokenSalt.java index 8e36310b..9b4e438a 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/TokenSalt.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/TokenSalt.java @@ -10,11 +10,16 @@ /** - * 32-byte salt mixed with a network identifier to derive a {@link TokenId}. + * Variable-length salt mixed with a network identifier to derive a {@link TokenId}. The minter + * chooses the length within {@code [{@link #MIN_LENGTH}, {@link #MAX_LENGTH}]} bytes: at least 128 + * bits of entropy, and an upper bound so untrusted token blobs cannot carry an arbitrarily large + * salt. */ public class TokenSalt { public static final int LENGTH = 32; + public static final int MAX_LENGTH = 64; + public static final int MIN_LENGTH = 16; private static final SecureRandom RANDOM = new SecureRandom(); private final byte[] bytes; @@ -24,17 +29,20 @@ private TokenSalt(byte[] bytes) { } /** - * Wrap an existing 32-byte salt. + * Wrap an existing salt. The salt is variable-length but must carry at least 128 bits of entropy + * and stay within the upper bound, so it must be between {@link TokenSalt#MIN_LENGTH} and + * {@link TokenSalt#MAX_LENGTH} bytes. * - * @param bytes salt bytes; must be exactly 32 bytes + * @param bytes salt bytes; must be 16 to 64 bytes * * @return token salt */ public static TokenSalt fromBytes(byte[] bytes) { Objects.requireNonNull(bytes, "Token salt cannot be null"); - if (bytes.length != TokenSalt.LENGTH) { + if (bytes.length < TokenSalt.MIN_LENGTH || bytes.length > TokenSalt.MAX_LENGTH) { throw new IllegalArgumentException( - "Token salt must be " + TokenSalt.LENGTH + " bytes long, got " + bytes.length); + "TokenSalt must be between " + TokenSalt.MIN_LENGTH + " and " + TokenSalt.MAX_LENGTH + + " bytes, got " + bytes.length + "."); } return new TokenSalt(Arrays.copyOf(bytes, bytes.length)); } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/TokenType.java b/src/main/java/org/unicitylabs/sdk/transaction/TokenType.java index b845cdd9..a5d54a7f 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/TokenType.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/TokenType.java @@ -14,16 +14,24 @@ */ public class TokenType { + public static final int MIN_LENGTH = 1; + public static final int MAX_LENGTH = 64; + private static final SecureRandom RANDOM = new SecureRandom(); private final byte[] bytes; /** * Create a token type from byte array. * - * @param bytes token type bytes + * @param bytes token type bytes; must be between {@link #MIN_LENGTH} and {@link #MAX_LENGTH} bytes */ public TokenType(byte[] bytes) { Objects.requireNonNull(bytes, "Token type cannot be null"); + if (bytes.length < TokenType.MIN_LENGTH || bytes.length > TokenType.MAX_LENGTH) { + throw new IllegalArgumentException( + String.format("Token type must be between %d and %d bytes, got %d.", + TokenType.MIN_LENGTH, TokenType.MAX_LENGTH, bytes.length)); + } this.bytes = Arrays.copyOf(bytes, bytes.length); } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java b/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java index 38791ea0..87bc0695 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/Transaction.java @@ -43,7 +43,7 @@ public interface Transaction { * * @return randomness bytes */ - byte[] getStateMask(); + StateMask getStateMask(); /** * Calculates the resulting state hash. diff --git a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java index 9390b389..cc90be53 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/TransferTransaction.java @@ -27,14 +27,14 @@ public class TransferTransaction implements Transaction { private final DataHash sourceStateHash; private final EncodedPredicate lockScript; private final EncodedPredicate recipient; - private final byte[] stateMask; + private final StateMask stateMask; private final byte[] data; private TransferTransaction( DataHash sourceStateHash, EncodedPredicate lockScript, EncodedPredicate recipient, - byte[] stateMask, + StateMask stateMask, byte[] data ) { this.sourceStateHash = sourceStateHash; @@ -70,8 +70,8 @@ public DataHash getSourceStateHash() { } @Override - public byte[] getStateMask() { - return Arrays.copyOf(this.stateMask, this.stateMask.length); + public StateMask getStateMask() { + return this.stateMask; } /** @@ -84,7 +84,7 @@ public byte[] getStateMask() { * @return created transfer transaction */ public static TransferTransaction create(Token token, Predicate recipient, - byte[] stateMask, byte[] data) { + StateMask stateMask, byte[] data) { Transaction transaction = token.getLatestTransaction(); return new TransferTransaction( @@ -118,7 +118,7 @@ public static TransferTransaction fromCbor(byte[] bytes, Token token) { return TransferTransaction.create( token, EncodedPredicate.fromCbor(data.get(1)), - CborDeserializer.decodeByteString(data.get(2)), + StateMask.fromCbor(data.get(2)), CborDeserializer.decodeNullable(data.get(3), CborDeserializer::decodeByteString) ); } @@ -129,7 +129,7 @@ public DataHash calculateStateHash() { .update( CborSerializer.encodeArray( CborSerializer.encodeByteString(this.sourceStateHash.getImprint()), - CborSerializer.encodeByteString(this.stateMask) + this.stateMask.toCbor() ) ) .digest(); @@ -149,7 +149,7 @@ public byte[] toCbor() { CborSerializer.encodeArray( CborSerializer.encodeUnsignedInteger(TransferTransaction.VERSION), EncodedPredicate.fromPredicate(this.recipient).toCbor(), - CborSerializer.encodeByteString(this.stateMask), + this.stateMask.toCbor(), CborSerializer.encodeNullable(this.data, CborSerializer::encodeByteString) ) ); @@ -180,7 +180,7 @@ public CertifiedTransferTransaction toCertifiedTransaction( public String toString() { return String.format( "TransferTransaction{sourceStateHash=%s, lockScript=%s, recipient=%s, stateMask=%s, data=%s}", - this.sourceStateHash, this.lockScript, this.recipient, HexConverter.encode(this.stateMask), + this.sourceStateHash, this.lockScript, this.recipient, this.stateMask, HexConverter.encode(this.data)); } } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java index a60d1a32..675efb54 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedMintTransactionVerificationRule.java @@ -1,19 +1,18 @@ package org.unicitylabs.sdk.transaction.verification; import org.unicitylabs.sdk.api.CertificationData; -import org.unicitylabs.sdk.api.bft.RootTrustBase; import org.unicitylabs.sdk.crypto.MintSigningService; import org.unicitylabs.sdk.crypto.secp256k1.SigningService; import org.unicitylabs.sdk.predicate.EncodedPredicate; import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; -import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; import org.unicitylabs.sdk.transaction.CertifiedMintTransaction; +import org.unicitylabs.sdk.transaction.Token; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.util.verification.VerificationStatus; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; +import java.util.function.Consumer; /** * Verification rule set for certified mint transactions. @@ -29,22 +28,21 @@ private CertifiedMintTransactionVerificationRule() { /** * Verify a certified mint transaction. * - * @param trustBase root trust base - * @param predicateVerifier predicate verifier - * @param mintJustificationVerifier mint justification verifier * @param transaction certified mint transaction to verify + * @param context shared verification context (trust base + registries) + * @param nestedTokenCollector collector receiving tokens embedded in the mint justification that + * the caller must verify * * @return verification result with child results for each validation step */ public static VerificationResult verify( - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - MintJustificationVerifierService mintJustificationVerifier, - CertifiedMintTransaction transaction + CertifiedMintTransaction transaction, + VerificationContext context, + Consumer nestedTokenCollector ) { List> results = new ArrayList<>(); - if (!transaction.getNetworkId().equals(trustBase.getNetworkId())) { + if (!transaction.getNetworkId().equals(context.getTrustBase().getNetworkId())) { results.add(new VerificationResult<>("MintNetworkMatchesTrustBaseRule", VerificationStatus.FAIL)); return new VerificationResult<>("CertifiedMintTransactionVerificationRule", VerificationStatus.FAIL, "Mint network does not match trust base.", results); @@ -69,15 +67,26 @@ public static VerificationResult verify( VerificationStatus.FAIL, "Invalid lock script", results); } - result = InclusionProofVerificationRule.verify(trustBase, predicateVerifier, - transaction.getInclusionProof(), transaction); + result = InclusionProofVerificationRule.verify(context.getTrustBase(), + context.getPredicateVerifier(), transaction.getInclusionProof(), transaction); results.add(result); if (result.getStatus() != InclusionProofVerificationStatus.OK) { return new VerificationResult<>("CertifiedMintTransactionVerificationRule", VerificationStatus.FAIL, "Inclusion proof verification failed", results); } - result = mintJustificationVerifier.verify(transaction); + result = context.getTokenIssuanceVerifier().verify(transaction); + results.add(result); + if (result.getStatus() != VerificationStatus.OK) { + return new VerificationResult<>( + "CertifiedMintTransactionVerificationRule", + VerificationStatus.FAIL, + "Invalid token issuance", + results + ); + } + + result = context.getMintJustificationVerifier().verify(transaction, nestedTokenCollector); results.add(result); if (result.getStatus() != VerificationStatus.OK) { return new VerificationResult<>( diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedTransferTransactionVerificationRule.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedTransferTransactionVerificationRule.java index 37612ed5..56c44206 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedTransferTransactionVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedTransferTransactionVerificationRule.java @@ -1,7 +1,5 @@ package org.unicitylabs.sdk.transaction.verification; -import org.unicitylabs.sdk.api.bft.RootTrustBase; -import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; import org.unicitylabs.sdk.transaction.CertifiedTransferTransaction; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.util.verification.VerificationStatus; @@ -22,20 +20,18 @@ private CertifiedTransferTransactionVerificationRule() { /** * Verify a certified transfer transaction against the previous transaction. * - * @param trustBase root trust base used for inclusion proof verification - * @param predicateVerifier predicate verifier used by inclusion proof verification * @param transaction certified transfer transaction to verify + * @param context shared verification context (trust base + registries) * * @return verification result with child results for each validation step */ public static VerificationResult verify( - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - CertifiedTransferTransaction transaction) { + CertifiedTransferTransaction transaction, + VerificationContext context) { ArrayList> results = new ArrayList>(); - VerificationResult result = InclusionProofVerificationRule.verify(trustBase, - predicateVerifier, transaction.getInclusionProof(), transaction); + VerificationResult result = InclusionProofVerificationRule.verify(context.getTrustBase(), + context.getPredicateVerifier(), transaction.getInclusionProof(), transaction); results.add(result); if (result.getStatus() != InclusionProofVerificationStatus.OK) { return new VerificationResult<>("CertifiedTransferTransactionVerificationRule", diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedUnicityIdMintTransactionVerificationRule.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedUnicityIdMintTransactionVerificationRule.java deleted file mode 100644 index a01a0e89..00000000 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/CertifiedUnicityIdMintTransactionVerificationRule.java +++ /dev/null @@ -1,96 +0,0 @@ -package org.unicitylabs.sdk.transaction.verification; - -import org.unicitylabs.sdk.api.bft.RootTrustBase; -import org.unicitylabs.sdk.predicate.EncodedPredicate; -import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; -import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; -import org.unicitylabs.sdk.unicityid.CertifiedUnicityIdMintTransaction; -import org.unicitylabs.sdk.util.verification.VerificationResult; -import org.unicitylabs.sdk.util.verification.VerificationStatus; - -import java.util.ArrayList; -import java.util.List; - -/** - * Verification rule for the genesis (mint) of a unicity id token. Validates the inclusion proof of - * the certified mint transaction, and optionally checks that the genesis lock script matches an - * expected issuer public key. - */ -public class CertifiedUnicityIdMintTransactionVerificationRule { - - private CertifiedUnicityIdMintTransactionVerificationRule() { - } - - /** - * Verify the certified unicity id mint transaction. - * - * @param trustBase root trust base - * @param predicateVerifier predicate verifier - * @param genesis certified unicity id mint transaction to verify - * @param issuerPublicKey expected issuer public key, or {@code null} to skip the lock-script - * issuer check (e.g., when minting a fresh token where no external issuer is being asserted) - * - * @return verification result - */ - public static VerificationResult verify( - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - CertifiedUnicityIdMintTransaction genesis, - byte[] issuerPublicKey - ) { - List> results = new ArrayList<>(); - - if (!genesis.getNetworkId().equals(trustBase.getNetworkId())) { - results.add(new VerificationResult<>("MintNetworkMatchesTrustBaseRule", - VerificationStatus.FAIL)); - return new VerificationResult<>( - "CertifiedUnicityIdMintTransactionVerificationRule", - VerificationStatus.FAIL, - "Mint network does not match trust base.", - results - ); - } - results.add(new VerificationResult<>("MintNetworkMatchesTrustBaseRule", - VerificationStatus.OK)); - - if (issuerPublicKey != null) { - EncodedPredicate expectedLockScript = EncodedPredicate.fromPredicate( - SignaturePredicate.create(issuerPublicKey)); - if (!expectedLockScript.equals(genesis.getLockScript())) { - results.add(new VerificationResult<>("IsLockScriptValidVerificationRule", - VerificationStatus.FAIL)); - return new VerificationResult<>( - "CertifiedUnicityIdMintTransactionVerificationRule", - VerificationStatus.FAIL, - "Lock script does not match expected unicity-id issuer.", - results - ); - } - results.add(new VerificationResult<>("IsLockScriptValidVerificationRule", - VerificationStatus.OK)); - } - - VerificationResult result = InclusionProofVerificationRule.verify( - trustBase, - predicateVerifier, - genesis.getInclusionProof(), - genesis - ); - results.add(result); - if (result.getStatus() != InclusionProofVerificationStatus.OK) { - return new VerificationResult<>( - "CertifiedUnicityIdMintTransactionVerificationRule", - VerificationStatus.FAIL, - String.format("Inclusion proof verification failed: %s", result.getStatus()), - results - ); - } - - return new VerificationResult<>( - "CertifiedUnicityIdMintTransactionVerificationRule", - VerificationStatus.OK, - "", - results - ); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java index 3b8f09e6..02d59610 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationRule.java @@ -59,6 +59,12 @@ public static VerificationResult verify(RootTr InclusionProofVerificationStatus.TRANSACTION_HASH_MISMATCH); } + if (!certificationData.getLockScript().equals(transaction.getLockScript()) + || !certificationData.getSourceStateHash().equals(transaction.getSourceStateHash())) { + return new VerificationResult<>("InclusionProofVerificationRule", + InclusionProofVerificationStatus.CERTIFICATION_DATA_MISMATCH); + } + StateId stateId = StateId.fromTransaction(transaction); if (!inclusionProof.getInclusionCertificate().verify(stateId, certificationData.getTransactionHash(), new DataHash(HashAlgorithm.SHA256, inclusionProof.getUnicityCertificate().getInputRecord().getHash()))) { return new VerificationResult<>("InclusionProofVerificationRule", diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java index c179fdd1..07acdda2 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/InclusionProofVerificationStatus.java @@ -8,6 +8,8 @@ public enum InclusionProofVerificationStatus { INVALID_TRUSTBASE, /** Certification data required for verification is missing. */ MISSING_CERTIFICATION_DATA, + /** Certification lock script or source state hash does not match the reconstructed transaction. */ + CERTIFICATION_DATA_MISMATCH, /** Transaction hash does not match the value referenced by the proof. */ TRANSACTION_HASH_MISMATCH, /** Proof authentication failed. */ diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/MintJustificationVerifier.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/MintJustificationVerifier.java index ae94df22..9d8e4e6c 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/MintJustificationVerifier.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/MintJustificationVerifier.java @@ -1,9 +1,12 @@ package org.unicitylabs.sdk.transaction.verification; import org.unicitylabs.sdk.transaction.CertifiedMintTransaction; +import org.unicitylabs.sdk.transaction.Token; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.util.verification.VerificationStatus; +import java.util.function.Consumer; + /** * Verifier for a specific kind of certified mint transaction justification, identified by a CBOR * tag. Implementations are registered with {@link MintJustificationVerifierService} and dispatched @@ -21,13 +24,17 @@ public interface MintJustificationVerifier { /** * Verify the justification of the given certified mint transaction. * + *

Implementations must not verify tokens embedded in the justification (for example, the + * burn token of a split) recursively. Instead they must pass each embedded token to + * {@code nestedTokenCollector}; the caller verifies them iteratively. + * * @param transaction certified mint transaction whose justification is being verified - * @param mintJustificationVerifierService dispatcher used to recursively verify nested mint - * justifications (for example, the burn token's mint chain) + * @param nestedTokenCollector collector receiving tokens embedded in the justification that the + * caller must verify * * @return verification result */ VerificationResult verify( CertifiedMintTransaction transaction, - MintJustificationVerifierService mintJustificationVerifierService); + Consumer nestedTokenCollector); } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/MintJustificationVerifierService.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/MintJustificationVerifierService.java index 2e1f34a5..b9d71b6b 100644 --- a/src/main/java/org/unicitylabs/sdk/transaction/verification/MintJustificationVerifierService.java +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/MintJustificationVerifierService.java @@ -2,16 +2,19 @@ import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; import org.unicitylabs.sdk.transaction.CertifiedMintTransaction; +import org.unicitylabs.sdk.transaction.Token; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.util.verification.VerificationStatus; import java.util.HashMap; import java.util.Map; +import java.util.function.Consumer; /** * Dispatcher for {@link MintJustificationVerifier} implementations. Verifiers are registered - * by their CBOR tag; on {@link #verify(CertifiedMintTransaction)} the service reads the tag of - * the mint transaction's justification and routes the verification to the matching verifier. + * by their CBOR tag; on {@link #verify(CertifiedMintTransaction, Consumer)} the service reads + * the tag of the mint transaction's justification and routes the verification to the matching + * verifier. * *

Mint transactions with no justification are accepted as OK without further checks. */ @@ -40,11 +43,14 @@ public MintJustificationVerifierService register(MintJustificationVerifier verif * Verify the mint justification carried by the given transaction. * * @param transaction certified mint transaction to verify + * @param nestedTokenCollector collector receiving tokens embedded in the justification that the + * caller must verify * * @return verification result; OK if the transaction has no justification, otherwise the result * of the verifier registered for the justification's CBOR tag */ - public VerificationResult verify(CertifiedMintTransaction transaction) { + public VerificationResult verify(CertifiedMintTransaction transaction, + Consumer nestedTokenCollector) { byte[] bytes = transaction.getJustification().orElse(null); if (bytes == null) { return new VerificationResult<>("MintJustificationVerification", VerificationStatus.OK); @@ -60,7 +66,7 @@ public VerificationResult verify(CertifiedMintTransaction tr ); } - VerificationResult result = verifier.verify(transaction, this); + VerificationResult result = verifier.verify(transaction, nestedTokenCollector); if (result.getStatus() != VerificationStatus.OK) { return new VerificationResult<>("MintJustificationVerification", VerificationStatus.FAIL, String.format("Verification failed for tag %s", tag.getTag()), result); } diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/TokenIssuanceVerifier.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/TokenIssuanceVerifier.java new file mode 100644 index 00000000..c1ca2726 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/TokenIssuanceVerifier.java @@ -0,0 +1,34 @@ +package org.unicitylabs.sdk.transaction.verification; + +import org.unicitylabs.sdk.transaction.CertifiedMintTransaction; +import org.unicitylabs.sdk.transaction.TokenType; +import org.unicitylabs.sdk.util.verification.VerificationResult; +import org.unicitylabs.sdk.util.verification.VerificationStatus; + +/** + * Application-supplied policy for tokens of a single {@link TokenType}. Plugged into + * {@link TokenIssuanceVerifierService}, which dispatches by token type and decides whether a + * token's genesis data is acceptable for its type. + * + *

Registering a policy is an application trust decision: cryptographic certification alone + * does not authorize an issuance, so a payment consumer should verify both the payload structure + * and its own issuance policy here. + */ +public interface TokenIssuanceVerifier { + + /** + * Get the token type this policy applies to. + * + * @return token type + */ + TokenType getTokenType(); + + /** + * Verify the genesis data and any application-level issuance policy. + * + * @param transaction genesis mint transaction to verify + * + * @return verification result + */ + VerificationResult verify(CertifiedMintTransaction transaction); +} diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/TokenIssuanceVerifierService.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/TokenIssuanceVerifierService.java new file mode 100644 index 00000000..710f0c98 --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/TokenIssuanceVerifierService.java @@ -0,0 +1,87 @@ +package org.unicitylabs.sdk.transaction.verification; + +import org.unicitylabs.sdk.transaction.CertifiedMintTransaction; +import org.unicitylabs.sdk.transaction.TokenType; +import org.unicitylabs.sdk.util.HexConverter; +import org.unicitylabs.sdk.util.verification.VerificationResult; +import org.unicitylabs.sdk.util.verification.VerificationStatus; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Registry that dispatches token verification to the right {@link TokenIssuanceVerifier} based on + * the token's type. A token type with no registered verifier is rejected by default, and only + * accepted when {@code rejectUnregisteredTypes} is explicitly set to {@code false}. + */ +public class TokenIssuanceVerifierService { + + private final Map verifiers = new HashMap<>(); + private final boolean rejectUnregisteredTypes; + + /** + * Create a token issuance verifier registry that rejects unregistered token types. + */ + public TokenIssuanceVerifierService() { + this(true); + } + + /** + * Create a token issuance verifier registry. + * + * @param rejectUnregisteredTypes when {@code true}, reject any token whose type has no + * registered issuance verifier + */ + public TokenIssuanceVerifierService(boolean rejectUnregisteredTypes) { + this.rejectUnregisteredTypes = rejectUnregisteredTypes; + } + + /** + * Register a policy for its declared token type. + * + * @param verifier verifier to register + * + * @return this service for fluent chaining + * + * @throws IllegalArgumentException if a policy is already registered for the token type + */ + public TokenIssuanceVerifierService register(TokenIssuanceVerifier verifier) { + Objects.requireNonNull(verifier, "verifier cannot be null"); + String key = HexConverter.encode(verifier.getTokenType().getBytes()); + if (this.verifiers.containsKey(key)) { + throw new IllegalArgumentException(String.format( + "Duplicate token issuance verifier for token type %s.", verifier.getTokenType())); + } + + this.verifiers.put(key, verifier); + return this; + } + + /** + * Verify a token's genesis against the policy registered for its type. + * + * @param transaction genesis mint transaction whose token data to verify + * + * @return verification result + */ + public VerificationResult verify(CertifiedMintTransaction transaction) { + TokenType tokenType = transaction.getTokenType(); + TokenIssuanceVerifier verifier = this.verifiers.get( + HexConverter.encode(tokenType.getBytes())); + if (verifier == null) { + if (this.rejectUnregisteredTypes) { + return new VerificationResult<>( + "TokenIssuanceVerification", + VerificationStatus.FAIL, + String.format("No token issuance verifier registered for token type %s.", + tokenType)); + } + + return new VerificationResult<>("TokenIssuanceVerification", VerificationStatus.OK); + } + + VerificationResult result = verifier.verify(transaction); + return new VerificationResult<>("TokenIssuanceVerification", result.getStatus(), "", result); + } +} diff --git a/src/main/java/org/unicitylabs/sdk/transaction/verification/VerificationContext.java b/src/main/java/org/unicitylabs/sdk/transaction/verification/VerificationContext.java new file mode 100644 index 00000000..b791fb8c --- /dev/null +++ b/src/main/java/org/unicitylabs/sdk/transaction/verification/VerificationContext.java @@ -0,0 +1,82 @@ +package org.unicitylabs.sdk.transaction.verification; + +import org.unicitylabs.sdk.api.bft.RootTrustBase; +import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; + +import java.util.Objects; + +/** + * Immutable bundle of the dependencies shared across a (possibly nested) token verification: the + * single root of trust, the predicate verifier, and the mint-justification and token-issuance + * registries. It holds no mutable state, so a nested (e.g. burned source) token is always + * verified under the same root of trust and registries as the outer token. + * + *

All verifiers must be supplied explicitly: a defaulted token-issuance verifier would be an + * empty fail-closed registry that rejects every token, so there is no sensible default. + */ +public final class VerificationContext { + + private final RootTrustBase trustBase; + private final PredicateVerifierService predicateVerifier; + private final MintJustificationVerifierService mintJustificationVerifier; + private final TokenIssuanceVerifierService tokenIssuanceVerifier; + + /** + * Create a verification context with all dependencies. + * + * @param trustBase root trust base for the network + * @param predicateVerifier predicate verifier + * @param mintJustificationVerifier mint justification registry + * @param tokenIssuanceVerifier token issuance registry + */ + public VerificationContext( + RootTrustBase trustBase, + PredicateVerifierService predicateVerifier, + MintJustificationVerifierService mintJustificationVerifier, + TokenIssuanceVerifierService tokenIssuanceVerifier + ) { + this.trustBase = Objects.requireNonNull(trustBase, "trustBase cannot be null"); + this.predicateVerifier = Objects.requireNonNull(predicateVerifier, + "predicateVerifier cannot be null"); + this.mintJustificationVerifier = Objects.requireNonNull(mintJustificationVerifier, + "mintJustificationVerifier cannot be null"); + this.tokenIssuanceVerifier = Objects.requireNonNull(tokenIssuanceVerifier, + "tokenIssuanceVerifier cannot be null"); + } + + /** + * Get the root trust base. + * + * @return trust base + */ + public RootTrustBase getTrustBase() { + return this.trustBase; + } + + /** + * Get the predicate verifier. + * + * @return predicate verifier + */ + public PredicateVerifierService getPredicateVerifier() { + return this.predicateVerifier; + } + + /** + * Get the mint justification registry. + * + * @return mint justification verifier + */ + public MintJustificationVerifierService getMintJustificationVerifier() { + return this.mintJustificationVerifier; + } + + /** + * Get the token issuance registry. + * + * @return token issuance verifier + */ + public TokenIssuanceVerifierService getTokenIssuanceVerifier() { + return this.tokenIssuanceVerifier; + } +} diff --git a/src/main/java/org/unicitylabs/sdk/unicityid/CertifiedUnicityIdMintTransaction.java b/src/main/java/org/unicitylabs/sdk/unicityid/CertifiedUnicityIdMintTransaction.java deleted file mode 100644 index e5024062..00000000 --- a/src/main/java/org/unicitylabs/sdk/unicityid/CertifiedUnicityIdMintTransaction.java +++ /dev/null @@ -1,188 +0,0 @@ -package org.unicitylabs.sdk.unicityid; - -import org.unicitylabs.sdk.api.InclusionProof; -import org.unicitylabs.sdk.api.NetworkId; -import org.unicitylabs.sdk.api.bft.RootTrustBase; -import org.unicitylabs.sdk.crypto.hash.DataHash; -import org.unicitylabs.sdk.predicate.EncodedPredicate; -import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; -import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; -import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.transaction.TokenId; -import org.unicitylabs.sdk.transaction.TokenType; -import org.unicitylabs.sdk.transaction.Transaction; -import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationRule; -import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationStatus; -import org.unicitylabs.sdk.util.verification.VerificationException; -import org.unicitylabs.sdk.util.verification.VerificationResult; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -/** - * Unicity id mint transaction bundled with a verified inclusion proof. - */ -public final class CertifiedUnicityIdMintTransaction implements Transaction { - - private final UnicityIdMintTransaction transaction; - private final InclusionProof inclusionProof; - - private CertifiedUnicityIdMintTransaction(UnicityIdMintTransaction transaction, - InclusionProof inclusionProof) { - this.transaction = transaction; - this.inclusionProof = inclusionProof; - } - - @Override - public Optional getData() { - return this.transaction.getData(); - } - - @Override - public EncodedPredicate getLockScript() { - return this.transaction.getLockScript(); - } - - @Override - public EncodedPredicate getRecipient() { - return this.transaction.getRecipient(); - } - - /** - * Returns the network identifier. - * - * @return network identifier - */ - public NetworkId getNetworkId() { - return this.transaction.getNetworkId(); - } - - @Override - public DataHash getSourceStateHash() { - return this.transaction.getSourceStateHash(); - } - - @Override - public byte[] getStateMask() { - return this.transaction.getStateMask(); - } - - /** - * Returns the token id derived from the unicity id. - * - * @return token id - */ - public TokenId getTokenId() { - return this.transaction.getTokenId(); - } - - /** - * Returns the token type. - * - * @return token type - */ - public TokenType getTokenType() { - return this.transaction.getTokenType(); - } - - /** - * Returns the target predicate. - * - * @return target predicate - */ - public SignaturePredicate getTargetPredicate() { - return this.transaction.getTargetPredicate(); - } - - /** - * Returns the unicity id. - * - * @return unicity id - */ - public UnicityId getUnicityId() { - return this.transaction.getUnicityId(); - } - - /** - * Returns the inclusion proof certifying this transaction. - * - * @return inclusion proof - */ - public InclusionProof getInclusionProof() { - return this.inclusionProof; - } - - /** - * Deserializes a certified unicity id mint transaction from CBOR. - * - * @param bytes CBOR-encoded certified mint transaction - * - * @return decoded certified mint transaction - */ - public static CertifiedUnicityIdMintTransaction fromCbor(byte[] bytes) { - List data = CborDeserializer.decodeArray(bytes, 2); - return new CertifiedUnicityIdMintTransaction( - UnicityIdMintTransaction.fromCbor(data.get(0)), - InclusionProof.fromCbor(data.get(1)) - ); - } - - /** - * Creates a certified unicity id mint transaction after verifying its inclusion proof. - * - * @param trustBase trust base used to verify inclusion proof signatures - * @param predicateVerifier predicate verifier service - * @param transaction unicity id mint transaction to certify - * @param inclusionProof inclusion proof for the transaction - * - * @return certified mint transaction - * - * @throws VerificationException if inclusion proof verification fails - */ - public static CertifiedUnicityIdMintTransaction fromTransaction( - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - UnicityIdMintTransaction transaction, - InclusionProof inclusionProof - ) { - Objects.requireNonNull(trustBase, "trustBase cannot be null"); - Objects.requireNonNull(predicateVerifier, "predicateVerifier cannot be null"); - Objects.requireNonNull(transaction, "transaction cannot be null"); - Objects.requireNonNull(inclusionProof, "inclusionProof cannot be null"); - - VerificationResult result = InclusionProofVerificationRule.verify( - trustBase, - predicateVerifier, - inclusionProof, - transaction - ); - if (result.getStatus() != InclusionProofVerificationStatus.OK) { - throw new VerificationException("Inclusion proof verification failed", result); - } - - return new CertifiedUnicityIdMintTransaction(transaction, inclusionProof); - } - - @Override - public DataHash calculateStateHash() { - return this.transaction.calculateStateHash(); - } - - @Override - public DataHash calculateTransactionHash() { - return this.transaction.calculateTransactionHash(); - } - - @Override - public byte[] toCbor() { - return CborSerializer.encodeArray(this.transaction.toCbor(), this.inclusionProof.toCbor()); - } - - @Override - public String toString() { - return String.format("CertifiedUnicityIdMintTransaction{transaction=%s, inclusionProof=%s}", - this.transaction, this.inclusionProof); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/unicityid/UnicityId.java b/src/main/java/org/unicitylabs/sdk/unicityid/UnicityId.java deleted file mode 100644 index d783e9bd..00000000 --- a/src/main/java/org/unicitylabs/sdk/unicityid/UnicityId.java +++ /dev/null @@ -1,127 +0,0 @@ -package org.unicitylabs.sdk.unicityid; - -import org.unicitylabs.sdk.crypto.hash.DataHash; -import org.unicitylabs.sdk.crypto.hash.DataHasher; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.transaction.TokenSalt; - -import java.util.List; -import java.util.Objects; - -/** - * Human-readable identifier for a unicity token. The pair (domain, name) is hashed deterministically - * to derive the corresponding {@link TokenSalt}. - */ -public final class UnicityId { - - private final String name; - private final String domain; - - /** - * Create a unicity id with name only (no domain). - * - * @param name token name - */ - public UnicityId(String name) { - this(name, null); - } - - /** - * Create a unicity id. - * - * @param name token name - * @param domain optional domain; may be null - */ - public UnicityId(String name, String domain) { - this.name = Objects.requireNonNull(name, "name cannot be null"); - this.domain = domain; - } - - /** - * Get the token name. - * - * @return name - */ - public String getName() { - return this.name; - } - - /** - * Get the optional domain. - * - * @return domain, or null if not set - */ - public String getDomain() { - return this.domain; - } - - /** - * Deserialize a unicity id from CBOR bytes. - * - * @param bytes CBOR bytes - * - * @return unicity id - */ - public static UnicityId fromCbor(byte[] bytes) { - List data = CborDeserializer.decodeArray(bytes, 2); - return new UnicityId( - CborDeserializer.decodeTextString(data.get(0)), - CborDeserializer.decodeNullable(data.get(1), CborDeserializer::decodeTextString) - ); - } - - /** - * Serialize the unicity id to CBOR bytes. - * - * @return CBOR bytes - */ - public byte[] toCbor() { - return CborSerializer.encodeArray( - CborSerializer.encodeTextString(this.name), - CborSerializer.encodeNullable(this.domain, CborSerializer::encodeTextString) - ); - } - - /** - * Derive the token salt from this unicity id by hashing the tagged ("NAMETAG_", domain, name) - * tuple with SHA-256. - * - * @return derived token salt - */ - public TokenSalt toTokenSalt() { - DataHash hash = new DataHasher(HashAlgorithm.SHA256) - .update( - CborSerializer.encodeArray( - CborSerializer.encodeTextString("NAMETAG_"), - CborSerializer.encodeNullable(this.domain, CborSerializer::encodeTextString), - CborSerializer.encodeTextString(this.name) - ) - ) - .digest(); - return TokenSalt.fromBytes(hash.getData()); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof UnicityId)) { - return false; - } - UnicityId that = (UnicityId) o; - return this.name.equals(that.name) && Objects.equals(this.domain, that.domain); - } - - @Override - public int hashCode() { - return Objects.hash(this.name, this.domain); - } - - @Override - public String toString() { - return "@" + (this.domain != null ? this.domain + "/" : "") + this.name; - } -} diff --git a/src/main/java/org/unicitylabs/sdk/unicityid/UnicityIdMintTransaction.java b/src/main/java/org/unicitylabs/sdk/unicityid/UnicityIdMintTransaction.java deleted file mode 100644 index 06d0f735..00000000 --- a/src/main/java/org/unicitylabs/sdk/unicityid/UnicityIdMintTransaction.java +++ /dev/null @@ -1,279 +0,0 @@ -package org.unicitylabs.sdk.unicityid; - -import org.unicitylabs.sdk.api.InclusionProof; -import org.unicitylabs.sdk.api.NetworkId; -import org.unicitylabs.sdk.api.bft.RootTrustBase; -import org.unicitylabs.sdk.crypto.hash.DataHash; -import org.unicitylabs.sdk.crypto.hash.DataHasher; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.predicate.EncodedPredicate; -import org.unicitylabs.sdk.predicate.Predicate; -import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; -import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; -import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; -import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.transaction.MintTransactionState; -import org.unicitylabs.sdk.transaction.TokenId; -import org.unicitylabs.sdk.transaction.TokenType; -import org.unicitylabs.sdk.transaction.Transaction; - -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -/** - * Mint transaction that derives its token id from a {@link UnicityId}. The token's data field is - * the encoded target predicate. - */ -public final class UnicityIdMintTransaction implements Transaction { - public static final long CBOR_TAG = 39041; - private static final int VERSION = 1; - - private final MintTransactionState sourceStateHash; - private final EncodedPredicate lockScript; - private final NetworkId networkId; - private final EncodedPredicate recipient; - private final TokenId tokenId; - private final TokenType tokenType; - private final SignaturePredicate targetPredicate; - private final UnicityId unicityId; - - private UnicityIdMintTransaction( - MintTransactionState sourceStateHash, - EncodedPredicate lockScript, - NetworkId networkId, - EncodedPredicate recipient, - TokenId tokenId, - TokenType tokenType, - SignaturePredicate targetPredicate, - UnicityId unicityId - ) { - this.sourceStateHash = sourceStateHash; - this.lockScript = lockScript; - this.networkId = networkId; - this.recipient = recipient; - this.tokenId = tokenId; - this.tokenType = tokenType; - this.targetPredicate = targetPredicate; - this.unicityId = unicityId; - } - - /** - * Get the version number. - * - * @return version - */ - public int getVersion() { - return UnicityIdMintTransaction.VERSION; - } - - @Override - public MintTransactionState getSourceStateHash() { - return this.sourceStateHash; - } - - @Override - public EncodedPredicate getLockScript() { - return this.lockScript; - } - - @Override - public EncodedPredicate getRecipient() { - return this.recipient; - } - - /** - * Get the network identifier. - * - * @return network identifier - */ - public NetworkId getNetworkId() { - return this.networkId; - } - - /** - * Get the token id derived from the unicity id. - * - * @return token id - */ - public TokenId getTokenId() { - return this.tokenId; - } - - /** - * Get the token type. - * - * @return token type - */ - public TokenType getTokenType() { - return this.tokenType; - } - - /** - * Get the target predicate (the predicate the minted token is locked to). - * - * @return target predicate - */ - public SignaturePredicate getTargetPredicate() { - return this.targetPredicate; - } - - /** - * Get the unicity id. - * - * @return unicity id - */ - public UnicityId getUnicityId() { - return this.unicityId; - } - - @Override - public Optional getData() { - return Optional.of(EncodedPredicate.fromPredicate(this.targetPredicate).toCbor()); - } - - @Override - public byte[] getStateMask() { - return this.tokenId.getBytes(); - } - - /** - * Create a unicity id mint transaction. The token id is derived from the unicity id; the lock - * script is supplied by the caller. - * - * @param networkId network identifier - * @param lockScript lock script predicate (the predicate that must be unlocked to spend this - * transaction) - * @param recipient recipient predicate - * @param unicityId unicity id producing the token id - * @param tokenType token type identifier - * @param targetPredicate target predicate the minted token will be locked to - * - * @return mint transaction - */ - public static UnicityIdMintTransaction create( - NetworkId networkId, - SignaturePredicate lockScript, - Predicate recipient, - UnicityId unicityId, - TokenType tokenType, - SignaturePredicate targetPredicate - ) { - Objects.requireNonNull(networkId, "Network ID must not be null"); - Objects.requireNonNull(lockScript, "lockScript cannot be null"); - Objects.requireNonNull(recipient, "recipient cannot be null"); - Objects.requireNonNull(unicityId, "unicityId cannot be null"); - Objects.requireNonNull(tokenType, "tokenType cannot be null"); - Objects.requireNonNull(targetPredicate, "targetPredicate cannot be null"); - - TokenId tokenId = TokenId.fromSalt(networkId, unicityId.toTokenSalt()); - - return new UnicityIdMintTransaction( - MintTransactionState.create(tokenId), - EncodedPredicate.fromPredicate(lockScript), - networkId, - EncodedPredicate.fromPredicate(recipient), - tokenId, - tokenType, - targetPredicate, - unicityId - ); - } - - /** - * Deserialize a unicity id mint transaction from CBOR bytes. - * - * @param bytes CBOR bytes - * - * @return mint transaction - * - * @throws CborSerializationException if the bytes do not carry the expected tag, version, or if - * the encoded token id does not match the unicity id - */ - public static UnicityIdMintTransaction fromCbor(byte[] bytes) { - CborDeserializer.CborTag tag = CborDeserializer.decodeTag(bytes); - if (tag.getTag() != UnicityIdMintTransaction.CBOR_TAG) { - throw new CborSerializationException(String.format("Invalid CBOR tag: %s", tag.getTag())); - } - List data = CborDeserializer.decodeArray(tag.getData(), 7); - - int version = CborDeserializer.decodeUnsignedInteger(data.get(0)).asInt(); - if (version != UnicityIdMintTransaction.VERSION) { - throw new CborSerializationException(String.format("Unsupported version: %s", version)); - } - - return UnicityIdMintTransaction.create( - NetworkId.fromId(CborDeserializer.decodeUnsignedInteger(data.get(1)).asShort()), - SignaturePredicate.fromPredicate( - EncodedPredicate.fromCbor(data.get(2)) - ), - EncodedPredicate.fromCbor(data.get(3)), - UnicityId.fromCbor(data.get(4)), - TokenType.fromCbor(data.get(5)), - SignaturePredicate.fromPredicate( - EncodedPredicate.fromCbor(data.get(6)) - ) - ); - } - - @Override - public DataHash calculateStateHash() { - return new DataHasher(HashAlgorithm.SHA256) - .update( - CborSerializer.encodeArray( - CborSerializer.encodeByteString(this.sourceStateHash.getImprint()), - CborSerializer.encodeByteString(this.getStateMask()) - ) - ) - .digest(); - } - - @Override - public DataHash calculateTransactionHash() { - return new DataHasher(HashAlgorithm.SHA256).update(this.toCbor()).digest(); - } - - @Override - public byte[] toCbor() { - return CborSerializer.encodeTag( - UnicityIdMintTransaction.CBOR_TAG, - CborSerializer.encodeArray( - CborSerializer.encodeUnsignedInteger(UnicityIdMintTransaction.VERSION), - CborSerializer.encodeUnsignedInteger(this.networkId.getId()), - this.lockScript.toCbor(), - this.recipient.toCbor(), - this.unicityId.toCbor(), - this.tokenType.toCbor(), - EncodedPredicate.fromPredicate(this.targetPredicate).toCbor() - ) - ); - } - - /** - * Build the certified version by attaching and verifying an inclusion proof. - * - * @param trustBase root trust base - * @param predicateVerifier predicate verifier - * @param inclusionProof inclusion proof - * - * @return certified mint transaction - */ - public CertifiedUnicityIdMintTransaction toCertifiedTransaction( - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - InclusionProof inclusionProof - ) { - return CertifiedUnicityIdMintTransaction.fromTransaction(trustBase, predicateVerifier, this, - inclusionProof); - } - - @Override - public String toString() { - return String.format( - "UnicityIdMintTransaction{networkId=%s, lockScript=%s, recipient=%s, tokenId=%s, tokenType=%s, unicityId=%s, targetPredicate=%s}", - this.networkId, this.lockScript, this.recipient, this.tokenId, this.tokenType, - this.unicityId, this.targetPredicate - ); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/unicityid/UnicityIdToken.java b/src/main/java/org/unicitylabs/sdk/unicityid/UnicityIdToken.java deleted file mode 100644 index d74a4c1d..00000000 --- a/src/main/java/org/unicitylabs/sdk/unicityid/UnicityIdToken.java +++ /dev/null @@ -1,159 +0,0 @@ -package org.unicitylabs.sdk.unicityid; - -import org.unicitylabs.sdk.api.bft.RootTrustBase; -import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; -import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.transaction.TokenId; -import org.unicitylabs.sdk.transaction.TokenType; -import org.unicitylabs.sdk.transaction.verification.CertifiedUnicityIdMintTransactionVerificationRule; -import org.unicitylabs.sdk.util.verification.VerificationException; -import org.unicitylabs.sdk.util.verification.VerificationResult; -import org.unicitylabs.sdk.util.verification.VerificationStatus; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -/** - * Token whose genesis is a {@link CertifiedUnicityIdMintTransaction}. The token's identifier is - * deterministically derived from a {@link UnicityId}. - */ -public final class UnicityIdToken { - - private final CertifiedUnicityIdMintTransaction genesis; - - private UnicityIdToken(CertifiedUnicityIdMintTransaction genesis) { - this.genesis = genesis; - } - - /** - * Returns the certified genesis mint transaction. - * - * @return genesis transaction - */ - public CertifiedUnicityIdMintTransaction getGenesis() { - return this.genesis; - } - - /** - * Returns the token id. - * - * @return token id - */ - public TokenId getId() { - return this.genesis.getTokenId(); - } - - /** - * Returns the token type. - * - * @return token type - */ - public TokenType getType() { - return this.genesis.getTokenType(); - } - - /** - * Returns the unicity id used to derive this token's identifier. - * - * @return unicity id - */ - public UnicityId getUnicityId() { - return this.genesis.getUnicityId(); - } - - /** - * Deserialize a unicity id token from CBOR bytes. - * - * @param bytes CBOR bytes - * - * @return decoded token - */ - public static UnicityIdToken fromCbor(byte[] bytes) { - List data = CborDeserializer.decodeArray(bytes, 1); - return new UnicityIdToken(CertifiedUnicityIdMintTransaction.fromCbor(data.get(0))); - } - - /** - * Build a unicity id token from a certified genesis transaction and verify it. - * - * @param trustBase trust base used for certification verification - * @param predicateVerifier predicate verifier service - * @param genesis certified mint transaction - * - * @return verified token - * - * @throws VerificationException if genesis verification fails - */ - public static UnicityIdToken mint( - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - CertifiedUnicityIdMintTransaction genesis - ) { - Objects.requireNonNull(trustBase, "trustBase cannot be null"); - Objects.requireNonNull(predicateVerifier, "predicateVerifier cannot be null"); - Objects.requireNonNull(genesis, "genesis cannot be null"); - - VerificationResult result = - CertifiedUnicityIdMintTransactionVerificationRule.verify( - trustBase, - predicateVerifier, - genesis, - null - ); - if (result.getStatus() != VerificationStatus.OK) { - throw new VerificationException("Invalid token genesis", result); - } - - return new UnicityIdToken(genesis); - } - - /** - * Serialize this token to CBOR bytes. - * - * @return CBOR bytes - */ - public byte[] toCbor() { - return CborSerializer.encodeArray(this.genesis.toCbor()); - } - - /** - * Verify the token by validating its certified mint transaction against an expected issuer. - * - * @param trustBase trust base used for certification verification - * @param predicateVerifier predicate verifier service - * @param issuerPublicKey expected issuer public key - * - * @return verification result - * @throws NullPointerException if {@code issuerPublicKey} is {@code null} - */ - public VerificationResult verify( - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - byte[] issuerPublicKey - ) { - Objects.requireNonNull(trustBase, "trustBase cannot be null"); - Objects.requireNonNull(predicateVerifier, "predicateVerifier cannot be null"); - Objects.requireNonNull(issuerPublicKey, "issuerPublicKey cannot be null"); - - List> results = new ArrayList<>(); - VerificationResult result = CertifiedUnicityIdMintTransactionVerificationRule.verify( - trustBase, - predicateVerifier, - this.genesis, - issuerPublicKey - ); - results.add(result); - if (result.getStatus() != VerificationStatus.OK) { - return new VerificationResult<>("TokenVerification", VerificationStatus.FAIL, "", results); - } - - return new VerificationResult<>("TokenVerification", VerificationStatus.OK, "", results); - } - - @Override - public String toString() { - return String.format("UnicityIdToken{genesis=%s}", this.genesis); - } -} diff --git a/src/main/java/org/unicitylabs/sdk/util/BigIntegerConverter.java b/src/main/java/org/unicitylabs/sdk/util/BigIntegerConverter.java index 9f3f0cae..ffe11982 100644 --- a/src/main/java/org/unicitylabs/sdk/util/BigIntegerConverter.java +++ b/src/main/java/org/unicitylabs/sdk/util/BigIntegerConverter.java @@ -40,6 +40,26 @@ public static BigInteger decode(byte[] data, int offset, int length) { return t; } + /** + * Encode BigInteger to a fixed-width big-endian byte array, left-padded with zeroes. + * + * @param value BigInteger + * @param length output length in bytes + * @return bytes of exactly {@code length} bytes + * @throws IllegalArgumentException if the value does not fit in {@code length} bytes + */ + public static byte[] encode(BigInteger value, int length) { + byte[] minimal = BigIntegerConverter.encode(value); + if (minimal.length > length) { + throw new IllegalArgumentException( + String.format("Value does not fit in %d bytes.", length)); + } + + byte[] result = new byte[length]; + System.arraycopy(minimal, 0, result, length - minimal.length, minimal.length); + return result; + } + /** * Encode BigInteger to bytes. * diff --git a/src/main/java/org/unicitylabs/sdk/util/BitString.java b/src/main/java/org/unicitylabs/sdk/util/BitString.java index 98e7bf7c..9ef6aca7 100644 --- a/src/main/java/org/unicitylabs/sdk/util/BitString.java +++ b/src/main/java/org/unicitylabs/sdk/util/BitString.java @@ -29,45 +29,6 @@ public static BitString fromBytes(byte[] data) { return new BitString(Arrays.copyOf(data, data.length)); } - /** - * Creates a BitString for LSB-first tree routing with reversed byte order. BigInteger bit 0 is - * bit 0 (LSB) of data[0], matching getBitAtDepth LSB convention. - * - * @param data input bytes - * @return BitString - */ - public static BitString fromBytesReversedLSB(byte[] data) { - byte[] reversed = new byte[data.length]; - for (int i = 0; i < data.length; i++) { - reversed[i] = data[data.length - 1 - i]; - } - return new BitString(reversed); - } - - /** - * Creates a BitString for MSB-first tree routing with reversed byte order. BigInteger bit 0 is - * bit 7 (MSB) of data[0], matching getBitAtDepth MSB convention. - * - * @param data input bytes - * @return BitString - */ - public static BitString fromBytesReversedMSB(byte[] data) { - byte[] reversed = new byte[data.length]; - for (int i = 0; i < data.length; i++) { - int b = data[data.length - 1 - i] & 0xFF; - int bitReversed = ((b & 0x80) >> 7) - | ((b & 0x40) >> 5) - | ((b & 0x20) >> 3) - | ((b & 0x10) >> 1) - | ((b & 0x08) << 1) - | ((b & 0x04) << 3) - | ((b & 0x02) << 5) - | ((b & 0x01) << 7); - reversed[i] = (byte) bitReversed; - } - return new BitString(reversed); - } - /** * Converts BitString to BigInteger by adding a leading byte 1 to input byte array. This is to * ensure that the BigInteger will retain the leading zero bits. diff --git a/src/test/java/org/unicitylabs/sdk/MockAggregatorServer.java b/src/test/java/org/unicitylabs/sdk/MockAggregatorServer.java index b81d3664..72e7966d 100644 --- a/src/test/java/org/unicitylabs/sdk/MockAggregatorServer.java +++ b/src/test/java/org/unicitylabs/sdk/MockAggregatorServer.java @@ -1,13 +1,11 @@ package org.unicitylabs.sdk; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import okhttp3.mockwebserver.Dispatcher; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; -import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.util.HashSet; @@ -77,7 +75,14 @@ private MockResponse handleRequest(RecordedRequest request) { } } - String method = extractJsonRpcMethod(request); + JsonNode jsonRequest = "POST".equals(request.getMethod()) + ? objectMapper.readTree(request.getBody().readUtf8()) + : null; + String method = jsonRequest != null && jsonRequest.has("method") + ? jsonRequest.get("method").asText() : null; + // Echo the request id back, as a JSON-RPC 2.0 server must. + String id = jsonRequest != null && jsonRequest.has("id") + ? jsonRequest.get("id").asText() : UUID.randomUUID().toString(); if (protectedMethods.contains(method) && expectedApiKey != null && !hasValidApiKey(request)) { return new MockResponse() @@ -86,7 +91,7 @@ private MockResponse handleRequest(RecordedRequest request) { .setBody("Unauthorized"); } - return generateSuccessResponse(method); + return generateSuccessResponse(method, id); } catch (Exception e) { return new MockResponse() @@ -104,17 +109,8 @@ private boolean hasValidApiKey(RecordedRequest request) { return false; } - private @Nullable String extractJsonRpcMethod(RecordedRequest request) throws JsonProcessingException { - if (!"POST".equals(request.getMethod())) { - return null; - } - JsonNode jsonRequest = objectMapper.readTree(request.getBody().readUtf8()); - return jsonRequest.has("method") ? jsonRequest.get("method").asText() : null; - } - - private MockResponse generateSuccessResponse(String method) { + private MockResponse generateSuccessResponse(String method, String id) { String responseBody; - String id = UUID.randomUUID().toString(); switch (method != null ? method : "") { case "certification_request": diff --git a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java index 7e8149bb..3f0d7467 100644 --- a/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java +++ b/src/test/java/org/unicitylabs/sdk/TestAggregatorClient.java @@ -7,8 +7,8 @@ import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; import org.unicitylabs.sdk.crypto.secp256k1.SigningService; import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; -import org.unicitylabs.sdk.smt.radix.FinalizedNodeBranch; import org.unicitylabs.sdk.smt.radix.SparseMerkleTree; +import org.unicitylabs.sdk.smt.radix.SparseMerkleTreeRootNode; import org.unicitylabs.sdk.util.verification.VerificationResult; import org.unicitylabs.sdk.util.verification.VerificationStatus; @@ -84,7 +84,7 @@ public CompletableFuture submitCertificationRequest(Certi @Override public CompletableFuture getInclusionProof(StateId stateId) { - FinalizedNodeBranch root = this.sparseMerkleTree.calculateRoot(); + SparseMerkleTreeRootNode root = this.sparseMerkleTree.calculateRoot(); if (!requests.containsKey(stateId)) { return CompletableFuture.completedFuture(InclusionProofFixture.createResponse(null, null, root.getHash(), this.signingService)); diff --git a/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java b/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java index 1ecafbf9..6bcff265 100644 --- a/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java +++ b/src/test/java/org/unicitylabs/sdk/TestApiKeyIntegration.java @@ -33,8 +33,9 @@ void setUp() throws Exception { mockServer.setExpectedApiKey(TEST_API_KEY); mockServer.start(); + // Mock server serves plain HTTP; allow the key over it for this local test only. clientWithApiKey = new JsonRpcAggregatorClient( - mockServer.getUrl(), TEST_API_KEY); + mockServer.getUrl(), TEST_API_KEY, true); clientWithoutApiKey = new JsonRpcAggregatorClient(mockServer.getUrl()); SigningService signingService = new SigningService( diff --git a/src/test/java/org/unicitylabs/sdk/api/CertificationResponseTest.java b/src/test/java/org/unicitylabs/sdk/api/CertificationResponseTest.java new file mode 100644 index 00000000..812fe734 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/api/CertificationResponseTest.java @@ -0,0 +1,16 @@ +package org.unicitylabs.sdk.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class CertificationResponseTest { + + @Test + void ignoresUnknownFields() { + CertificationResponse response = + CertificationResponse.fromJson( + "{\"status\":\"SUCCESS\",\"unknownField\":\"value\",\"another\":123}"); + + Assertions.assertEquals(CertificationStatus.SUCCESS, response.getStatus()); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java index 8ead4eb3..2a1a070c 100644 --- a/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/InclusionProofTest.java @@ -4,7 +4,6 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; -import org.unicitylabs.sdk.api.NetworkId; import org.unicitylabs.sdk.api.bft.RootTrustBase; import org.unicitylabs.sdk.api.bft.RootTrustBaseUtils; import org.unicitylabs.sdk.api.bft.ShardId; @@ -16,8 +15,8 @@ import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; import org.unicitylabs.sdk.predicate.builtin.SignaturePredicateUnlockScript; import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; -import org.unicitylabs.sdk.smt.radix.FinalizedNodeBranch; import org.unicitylabs.sdk.smt.radix.SparseMerkleTree; +import org.unicitylabs.sdk.smt.radix.SparseMerkleTreeRootNode; import org.unicitylabs.sdk.transaction.MintTransaction; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationRule; import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationStatus; @@ -51,7 +50,7 @@ public void createMerkleTreePath() throws Exception { SparseMerkleTree smt = new SparseMerkleTree(HashAlgorithm.SHA256); smt.addLeaf(stateId.getData(), certificationData.getTransactionHash().getData()); - FinalizedNodeBranch root = smt.calculateRoot(); + SparseMerkleTreeRootNode root = smt.calculateRoot(); inclusionCertificate = InclusionCertificate.create(root, stateId.getData()); // Reuse user signing service as unicity certificate signing service. trustBase = RootTrustBaseUtils.generateRootTrustBase(signingService.getPublicKey()); diff --git a/src/test/java/org/unicitylabs/sdk/api/JsonRpcAggregatorClientTest.java b/src/test/java/org/unicitylabs/sdk/api/JsonRpcAggregatorClientTest.java new file mode 100644 index 00000000..21c2ab76 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/api/JsonRpcAggregatorClientTest.java @@ -0,0 +1,38 @@ +package org.unicitylabs.sdk.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Construction-time transport-security checks for {@link JsonRpcAggregatorClient} (H-03), + * mirroring the JS {@code AggregatorClientTest}. + */ +public class JsonRpcAggregatorClientTest { + + @Test + public void rejectsApiKeyOverPlaintextHttp() { + IllegalArgumentException exception = Assertions.assertThrows( + IllegalArgumentException.class, + () -> new JsonRpcAggregatorClient("http://example.com", "secret-key")); + Assertions.assertEquals( + "API key must not be sent over plaintext HTTP; use an https url.", + exception.getMessage()); + } + + @Test + public void allowsApiKeyOverHttps() { + Assertions.assertDoesNotThrow( + () -> new JsonRpcAggregatorClient("https://example.com", "secret-key")); + } + + @Test + public void allowsPlaintextHttpWhenNoApiKey() { + Assertions.assertDoesNotThrow(() -> new JsonRpcAggregatorClient("http://example.com")); + } + + @Test + public void allowsApiKeyOverHttpWhenInsecureTransportAllowed() { + Assertions.assertDoesNotThrow( + () -> new JsonRpcAggregatorClient("http://example.com", "secret-key", true)); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/api/NetworkIdTest.java b/src/test/java/org/unicitylabs/sdk/api/NetworkIdTest.java new file mode 100644 index 00000000..0f485849 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/api/NetworkIdTest.java @@ -0,0 +1,59 @@ +package org.unicitylabs.sdk.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.serializer.UnicityObjectMapper; +import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; +import org.unicitylabs.sdk.serializer.cbor.CborSerializer; + +public class NetworkIdTest { + + @Test + void rejectsZero() { + Assertions.assertThrows(IllegalArgumentException.class, () -> NetworkId.fromId((short) 0)); + } + + @Test + void exposesUnsignedIdAboveSignedShortRange() { + NetworkId networkId = NetworkId.fromId((short) 0x8000); + Assertions.assertEquals(0x8000, networkId.getId()); + Assertions.assertEquals(0xFFFF, NetworkId.fromId((short) 0xFFFF).getId()); + Assertions.assertEquals(NetworkId.fromId((short) 0x8000), networkId); + } + + @Test + void cborRoundTripPreservesIdsAboveSignedShortRange() { + for (int id : new int[] {1, 3, 0x7FFF, 0x8000, 0xC000, 0xFFFF}) { + NetworkId networkId = NetworkId.fromId((short) id); + + byte[] encoded = CborSerializer.encodeUnsignedInteger(networkId.getId()); + NetworkId decoded = + NetworkId.fromId(CborDeserializer.decodeUnsignedInteger(encoded).asShort()); + + Assertions.assertEquals(id, decoded.getId()); + Assertions.assertEquals(networkId, decoded); + } + } + + @Test + void jsonRoundTripPreservesIdsAboveSignedShortRange() throws Exception { + NetworkId networkId = NetworkId.fromId((short) 0x8000); + + String json = UnicityObjectMapper.JSON.writeValueAsString(networkId); + Assertions.assertEquals("32768", json); + + NetworkId decoded = UnicityObjectMapper.JSON.readValue(json, NetworkId.class); + Assertions.assertEquals(networkId, decoded); + Assertions.assertEquals(0x8000, decoded.getId()); + } + + @Test + void jsonRejectsIdsOutsideUnsignedShortRange() { + Assertions.assertThrows( + Exception.class, + () -> UnicityObjectMapper.JSON.readValue("70000", NetworkId.class)); + Assertions.assertThrows( + Exception.class, + () -> UnicityObjectMapper.JSON.readValue("0", NetworkId.class)); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/api/bft/RootTrustBaseTest.java b/src/test/java/org/unicitylabs/sdk/api/bft/RootTrustBaseTest.java index 6a2041af..9150ce1a 100644 --- a/src/test/java/org/unicitylabs/sdk/api/bft/RootTrustBaseTest.java +++ b/src/test/java/org/unicitylabs/sdk/api/bft/RootTrustBaseTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.unicitylabs.sdk.api.NetworkId; +import org.unicitylabs.sdk.serializer.json.JsonSerializationException; public class RootTrustBaseTest { @@ -24,4 +25,80 @@ public void testRootTrustBaseDeserializationFromJson() { Assertions.assertEquals(4, trustBase.getSignatures().size()); } + @Test + public void testParsesValidTrustBase() { + RootTrustBase trustBase = RootTrustBase.fromJson(trustBaseJson( + 1, 1, "[{\"nodeId\":\"NODE\",\"sigKey\":\"0x02aa\",\"stake\":1}]")); + + Assertions.assertEquals(1, trustBase.getVersion()); + Assertions.assertEquals(1, trustBase.getQuorumThreshold()); + Assertions.assertEquals(1, trustBase.getRootNodes().size()); + } + + @Test + public void testRejectsUnsupportedVersion() { + Assertions.assertThrows( + JsonSerializationException.class, + () -> RootTrustBase.fromJson(trustBaseJson( + 2, 1, "[{\"nodeId\":\"NODE\",\"sigKey\":\"0x02aa\",\"stake\":1}]"))); + } + + @Test + public void testRejectsEmptyRootNodeSet() { + Assertions.assertThrows( + JsonSerializationException.class, + () -> RootTrustBase.fromJson(trustBaseJson(1, 1, "[]"))); + } + + @Test + public void testRejectsNonPositiveQuorumThreshold() { + Assertions.assertThrows( + JsonSerializationException.class, + () -> RootTrustBase.fromJson(trustBaseJson( + 1, 0, "[{\"nodeId\":\"NODE\",\"sigKey\":\"0x02aa\",\"stake\":1}]"))); + } + + @Test + public void testRejectsQuorumThresholdExceedingRootNodeCount() { + Assertions.assertThrows( + JsonSerializationException.class, + () -> RootTrustBase.fromJson(trustBaseJson( + 1, 2, "[{\"nodeId\":\"NODE\",\"sigKey\":\"0x02aa\",\"stake\":1}]"))); + } + + @Test + public void testRejectsNonPositiveStake() { + Assertions.assertThrows( + JsonSerializationException.class, + () -> RootTrustBase.fromJson(trustBaseJson( + 1, 1, "[{\"nodeId\":\"NODE\",\"sigKey\":\"0x02aa\",\"stake\":0}]"))); + } + + @Test + public void testRejectsDuplicateNodeIds() { + Assertions.assertThrows( + JsonSerializationException.class, + () -> RootTrustBase.fromJson(trustBaseJson( + 1, 1, + "[{\"nodeId\":\"NODE\",\"sigKey\":\"0x02aa\",\"stake\":1}," + + "{\"nodeId\":\"NODE\",\"sigKey\":\"0x02bb\",\"stake\":1}]"))); + } + + @Test + public void testRejectsDuplicateSigningKeys() { + Assertions.assertThrows( + JsonSerializationException.class, + () -> RootTrustBase.fromJson(trustBaseJson( + 1, 1, + "[{\"nodeId\":\"NODE_A\",\"sigKey\":\"0x02aa\",\"stake\":1}," + + "{\"nodeId\":\"NODE_B\",\"sigKey\":\"0x02aa\",\"stake\":1}]"))); + } + + private static String trustBaseJson(long version, long quorumThreshold, String rootNodes) { + return String.format( + "{\"version\":%d,\"networkId\":3,\"epoch\":0,\"epochStartRound\":0,\"rootNodes\":%s," + + "\"quorumThreshold\":%d,\"stateHash\":\"\",\"changeRecordHash\":null," + + "\"previousEntryHash\":null,\"signatures\":{}}", + version, rootNodes, quorumThreshold); + } } diff --git a/src/test/java/org/unicitylabs/sdk/api/bft/RootTrustBaseUtils.java b/src/test/java/org/unicitylabs/sdk/api/bft/RootTrustBaseUtils.java index 87364c89..598df798 100644 --- a/src/test/java/org/unicitylabs/sdk/api/bft/RootTrustBaseUtils.java +++ b/src/test/java/org/unicitylabs/sdk/api/bft/RootTrustBaseUtils.java @@ -2,17 +2,17 @@ import org.unicitylabs.sdk.api.NetworkId; +import java.util.List; import java.util.Map; -import java.util.Set; public class RootTrustBaseUtils { public static RootTrustBase generateRootTrustBase(byte[] publicKey) { return new RootTrustBase( - 0, + 1, NetworkId.LOCAL, 0L, 0L, - Set.of( + List.of( new RootTrustBase.NodeInfo( "NODE", publicKey, diff --git a/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java b/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java index 24d397a3..1ea13549 100644 --- a/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java +++ b/src/test/java/org/unicitylabs/sdk/api/bft/UnicityCertificateUtils.java @@ -95,7 +95,7 @@ public static UnicityCertificate generateCertificate( "NODE", signingService.sign( new DataHasher(HashAlgorithm.SHA256).update(seal.toCbor()).digest() - ).encode() + ) ) ) ) diff --git a/src/test/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcHttpTransportTest.java b/src/test/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcHttpTransportTest.java new file mode 100644 index 00000000..63843feb --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcHttpTransportTest.java @@ -0,0 +1,83 @@ +package org.unicitylabs.sdk.api.jsonrpc; + +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +/** + * Transport hardening tests (H-03): redirects must not be followed, so authentication headers are + * never replayed to a redirect target. + */ +public class JsonRpcHttpTransportTest { + + private MockWebServer server; + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + @Test + public void doesNotFollowRedirects() throws Exception { + // A redirect the client must NOT follow; its target would receive the replayed request. + server.enqueue(new MockResponse() + .setResponseCode(302) + .setHeader("Location", "https://attacker.example.com/steal")); + + JsonRpcHttpTransport transport = new JsonRpcHttpTransport(server.url("/").toString()); + + ExecutionException error = Assertions.assertThrows( + ExecutionException.class, + () -> transport.request("method", "params", String.class, Map.of()) + .get(5, TimeUnit.SECONDS)); + Assertions.assertInstanceOf(JsonRpcNetworkException.class, error.getCause()); + Assertions.assertEquals(302, ((JsonRpcNetworkException) error.getCause()).getStatus()); + + // Exactly one request was made: the redirect was surfaced, not followed. + Assertions.assertEquals(1, server.getRequestCount()); + RecordedRequest request = server.takeRequest(); + Assertions.assertEquals("/", request.getPath()); + } + + @Test + public void normalizesCallerSuppliedClientToNotFollowRedirects() throws Exception { + server.enqueue(new MockResponse() + .setResponseCode(302) + .setHeader("Location", server.url("/redirected").toString())); + server.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("{\"jsonrpc\":\"2.0\",\"result\":\"OK\",\"id\":\"x\"}")); + + OkHttpClient callerClient = new OkHttpClient.Builder() + .followRedirects(true) + .followSslRedirects(true) + .build(); + JsonRpcHttpTransport transport = new JsonRpcHttpTransport(server.url("/").toString(), callerClient); + + ExecutionException error = Assertions.assertThrows( + ExecutionException.class, + () -> transport.request("method", "params", String.class, Map.of()) + .get(5, TimeUnit.SECONDS)); + Assertions.assertInstanceOf(JsonRpcNetworkException.class, error.getCause()); + Assertions.assertEquals(302, ((JsonRpcNetworkException) error.getCause()).getStatus()); + + Assertions.assertEquals(1, server.getRequestCount()); + Assertions.assertEquals("/", server.takeRequest().getPath()); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcHttpTransportValidationTest.java b/src/test/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcHttpTransportValidationTest.java new file mode 100644 index 00000000..b0a6428e --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/api/jsonrpc/JsonRpcHttpTransportValidationTest.java @@ -0,0 +1,127 @@ +package org.unicitylabs.sdk.api.jsonrpc; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +/** + * JSON-RPC response validation (L-02): id correlation, result/error exclusivity, version, and a + * response body size limit. + */ +public class JsonRpcHttpTransportValidationTest { + + private MockWebServer server; + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + private static MockResponse json(String body) { + return new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body); + } + + private Throwable causeOf(JsonRpcHttpTransport transport) { + ExecutionException error = Assertions.assertThrows( + ExecutionException.class, + () -> transport.request("method", "params", String.class, Map.of()) + .get(5, TimeUnit.SECONDS)); + return error.getCause(); + } + + /** + * Serve a body whose {@code %s} is filled with the actual request id, so checks that run after + * id correlation can be targeted in isolation. + */ + private Throwable requestWithEchoedId(String bodyTemplate) { + server.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + String body = request.getBody().readUtf8(); + int start = body.indexOf("\"id\":\"") + 6; + String id = body.substring(start, body.indexOf('"', start)); + return json(String.format(bodyTemplate, id)); + } + }); + return causeOf(new JsonRpcHttpTransport(server.url("/").toString())); + } + + @Test + public void rejectsResponseWithMismatchedId() { + server.enqueue(json( + "{\"jsonrpc\":\"2.0\",\"result\":\"OK\"," + + "\"id\":\"11111111-1111-1111-1111-111111111111\"}")); + Throwable cause = causeOf(new JsonRpcHttpTransport(server.url("/").toString())); + Assertions.assertTrue(cause.getMessage().contains("id mismatch"), cause.getMessage()); + } + + @Test + public void surfacesServerErrorWithNullIdInsteadOfIdMismatch() { + server.enqueue(json( + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32700,\"message\":\"Parse error\"}," + + "\"id\":null}")); + Throwable cause = causeOf(new JsonRpcHttpTransport(server.url("/").toString())); + Assertions.assertInstanceOf(JsonRpcNetworkException.class, cause); + Assertions.assertEquals(-32700, ((JsonRpcNetworkException) cause).getStatus()); + } + + @Test + public void rejectsResponseWithNeitherResultNorError() { + Throwable cause = requestWithEchoedId("{\"jsonrpc\":\"2.0\",\"id\":\"%s\"}"); + Assertions.assertTrue(cause.getMessage().contains("exactly one"), cause.getMessage()); + } + + @Test + public void rejectsResponseWithBothResultAndError() { + Throwable cause = requestWithEchoedId( + "{\"jsonrpc\":\"2.0\",\"result\":\"OK\",\"error\":{\"code\":1,\"message\":\"x\"}," + + "\"id\":\"%s\"}"); + Assertions.assertTrue(cause.getMessage().contains("exactly one"), cause.getMessage()); + } + + @Test + public void rejectsUnsupportedVersion() { + Throwable cause = requestWithEchoedId("{\"jsonrpc\":\"1.0\",\"result\":\"OK\",\"id\":\"%s\"}"); + Assertions.assertTrue(cause.getMessage().contains("Invalid JSON-RPC version"), + cause.getMessage()); + } + + @Test + public void rejectsNonPositiveMaxResponseBytes() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new JsonRpcHttpTransport(server.url("/").toString(), 0)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new JsonRpcHttpTransport(server.url("/").toString(), -1)); + } + + @Test + public void rejectsOversizedResponse() { + StringBuilder big = new StringBuilder("{\"jsonrpc\":\"2.0\",\"result\":\""); + for (int i = 0; i < 5000; i++) { + big.append('a'); + } + big.append("\",\"id\":\"x\"}"); + + server.enqueue(json(big.toString())); + Throwable cause = causeOf(new JsonRpcHttpTransport(server.url("/").toString(), 1024)); + Assertions.assertTrue(cause.getMessage().contains("maximum allowed size"), cause.getMessage()); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/common/CommonTestFlow.java b/src/test/java/org/unicitylabs/sdk/common/CommonTestFlow.java index f2861fb8..c7e75386 100644 --- a/src/test/java/org/unicitylabs/sdk/common/CommonTestFlow.java +++ b/src/test/java/org/unicitylabs/sdk/common/CommonTestFlow.java @@ -3,22 +3,10 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.unicitylabs.sdk.StateTransitionClient; -import org.unicitylabs.sdk.api.CertificationData; -import org.unicitylabs.sdk.api.CertificationResponse; -import org.unicitylabs.sdk.api.CertificationStatus; -import org.unicitylabs.sdk.api.bft.RootTrustBase; import org.unicitylabs.sdk.crypto.secp256k1.SigningService; import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; -import org.unicitylabs.sdk.predicate.builtin.SignaturePredicateUnlockScript; -import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; import org.unicitylabs.sdk.transaction.Token; -import org.unicitylabs.sdk.transaction.TokenType; -import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; -import org.unicitylabs.sdk.unicityid.UnicityId; -import org.unicitylabs.sdk.unicityid.UnicityIdMintTransaction; -import org.unicitylabs.sdk.unicityid.UnicityIdToken; -import org.unicitylabs.sdk.util.HexConverter; -import org.unicitylabs.sdk.util.InclusionProofUtils; +import org.unicitylabs.sdk.transaction.verification.VerificationContext; import org.unicitylabs.sdk.util.verification.VerificationStatus; import org.unicitylabs.sdk.utils.TokenUtils; @@ -28,14 +16,13 @@ public abstract class CommonTestFlow { protected StateTransitionClient client; - protected RootTrustBase trustBase; - protected PredicateVerifierService predicateVerifier; - protected MintJustificationVerifierService mintJustificationVerifier; + protected VerificationContext context; private static final SigningService ALICE_SIGNING_SERVICE = SigningService.generate(); private static final SigningService BOB_SIGNING_SERVICE = SigningService.generate(); private static final SigningService CAROL_SIGNING_SERVICE = SigningService.generate(); + /** * Test basic token transfer flow: Alice -> Bob -> Carol */ @@ -43,17 +30,13 @@ public abstract class CommonTestFlow { public void testTransferFlow() throws Exception { Token aliceToken = TokenUtils.mintToken( this.client, - this.trustBase, - this.predicateVerifier, - this.mintJustificationVerifier, + this.context, SignaturePredicate.create(ALICE_SIGNING_SERVICE.getPublicKey()) ); Token bobToken = TokenUtils.transferToken( this.client, - this.trustBase, - this.predicateVerifier, - this.mintJustificationVerifier, + this.context, aliceToken.toCbor(), SignaturePredicate.create(BOB_SIGNING_SERVICE.getPublicKey()), ALICE_SIGNING_SERVICE @@ -61,79 +44,13 @@ public void testTransferFlow() throws Exception { Token carolToken = TokenUtils.transferToken( this.client, - this.trustBase, - this.predicateVerifier, - this.mintJustificationVerifier, + this.context, bobToken.toCbor(), SignaturePredicate.create(CAROL_SIGNING_SERVICE.getPublicKey()), BOB_SIGNING_SERVICE ); Assertions.assertEquals(VerificationStatus.OK, - carolToken.verify(this.trustBase, this.predicateVerifier, this.mintJustificationVerifier).getStatus()); - } - - /** - * Default successful flow: mint a unicity-id token and then mint a regular token whose recipient - * is the unicity-id token's target predicate. - */ - @Test - public void testUnicityIdMintFlow() throws Exception { - SigningService unicityIdSigningService = SigningService.generate(); - SignaturePredicate targetPredicate = SignaturePredicate.create( - ALICE_SIGNING_SERVICE.getPublicKey()); - - UnicityId unicityId = new UnicityId("testuser", "unicity-labs/test"); - UnicityIdMintTransaction unicityIdMintTransaction = UnicityIdMintTransaction.create( - this.trustBase.getNetworkId(), - SignaturePredicate.fromSigningService(unicityIdSigningService), - targetPredicate, - unicityId, - TokenType.generate(), - targetPredicate - ); - - CertificationData unicityIdCertificationData = CertificationData.fromTransaction( - unicityIdMintTransaction, - SignaturePredicateUnlockScript.create(unicityIdMintTransaction, unicityIdSigningService) - ); - - CertificationResponse unicityIdResponse = this.client - .submitCertificationRequest(unicityIdCertificationData).get(); - Assertions.assertEquals(CertificationStatus.SUCCESS, unicityIdResponse.getStatus()); - - UnicityIdToken aliceUnicityIdToken = UnicityIdToken.mint( - this.trustBase, - this.predicateVerifier, - unicityIdMintTransaction.toCertifiedTransaction( - this.trustBase, - this.predicateVerifier, - InclusionProofUtils.waitInclusionProof(this.client, this.trustBase, - this.predicateVerifier, unicityIdMintTransaction).get() - ) - ); - - Assertions.assertEquals(VerificationStatus.OK, - aliceUnicityIdToken.verify(this.trustBase, this.predicateVerifier, - unicityIdSigningService.getPublicKey()).getStatus()); - - UnicityIdToken decodedUnicityIdToken = UnicityIdToken.fromCbor(aliceUnicityIdToken.toCbor()); - Assertions.assertArrayEquals(aliceUnicityIdToken.toCbor(), decodedUnicityIdToken.toCbor()); - Assertions.assertEquals(aliceUnicityIdToken.getId(), decodedUnicityIdToken.getId()); - Assertions.assertEquals(VerificationStatus.OK, - decodedUnicityIdToken.verify(this.trustBase, this.predicateVerifier, - unicityIdSigningService.getPublicKey()).getStatus()); - - Token aliceToken = TokenUtils.mintToken( - this.client, - this.trustBase, - this.predicateVerifier, - this.mintJustificationVerifier, - aliceUnicityIdToken.getGenesis().getTargetPredicate() - ); - - Assertions.assertEquals(VerificationStatus.OK, - aliceToken.verify(this.trustBase, this.predicateVerifier, this.mintJustificationVerifier) - .getStatus()); + carolToken.verify(this.context).getStatus()); } } \ No newline at end of file diff --git a/src/test/java/org/unicitylabs/sdk/crypto/secp256k1/SignatureRecoveryTest.java b/src/test/java/org/unicitylabs/sdk/crypto/secp256k1/SignatureRecoveryTest.java index 99dacf64..682ac0e9 100644 --- a/src/test/java/org/unicitylabs/sdk/crypto/secp256k1/SignatureRecoveryTest.java +++ b/src/test/java/org/unicitylabs/sdk/crypto/secp256k1/SignatureRecoveryTest.java @@ -35,7 +35,7 @@ void testSignatureRecoveryId() { // Verify signature with known public key byte[] publicKey = signingService.getPublicKey(); - assertTrue(SigningService.verifyWithPublicKey(hash, signature.getBytes(), publicKey)); + assertTrue(SigningService.verify(hash, signature.getBytes(), publicKey)); } @Test @@ -55,7 +55,7 @@ void testPublicKeyRecovery() { Signature signature = signingService.sign(hash); // Verify signature using recovered public key - assertTrue(SigningService.verifySignatureWithRecoveredPublicKey(hash, signature), + assertTrue(SigningService.verifyWithRecoveredPublicKey(hash, signature), "Signature verification with recovered public key should succeed"); } @@ -82,7 +82,7 @@ void testSignatureFormatCompliance() { DataHash transactionHash = DataHash.fromImprint(HexConverter.decode(transactionHashHex)); // Verify using recovered public key - assertTrue(SigningService.verifySignatureWithRecoveredPublicKey(transactionHash, signature), + assertTrue(SigningService.verifyWithRecoveredPublicKey(transactionHash, signature), "Should verify with recovered public key"); } } \ No newline at end of file diff --git a/src/test/java/org/unicitylabs/sdk/crypto/secp256k1/SignatureTest.java b/src/test/java/org/unicitylabs/sdk/crypto/secp256k1/SignatureTest.java new file mode 100644 index 00000000..879d6683 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/crypto/secp256k1/SignatureTest.java @@ -0,0 +1,40 @@ +package org.unicitylabs.sdk.crypto.secp256k1; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Recovery-id validation for {@link Signature#decode} (L-01), mirroring the JS + * {@code SignatureTest}. + */ +public class SignatureTest { + + private static byte[] sigBytes(int recovery) { + byte[] bytes = new byte[65]; + bytes[64] = (byte) recovery; + return bytes; + } + + @Test + public void rejectsRecoveryIdOutOfRange() { + Assertions.assertTrue(Assertions.assertThrows( + IllegalArgumentException.class, + () -> Signature.decode(sigBytes(4))).getMessage().contains("Invalid signature recovery id")); + Assertions.assertTrue(Assertions.assertThrows( + IllegalArgumentException.class, + () -> Signature.decode(sigBytes(255))).getMessage().contains("Invalid signature recovery id")); + } + + @Test + public void acceptsRecoveryIdsZeroThroughThree() { + for (int recovery = 0; recovery <= 3; recovery++) { + Assertions.assertEquals(recovery, Signature.decode(sigBytes(recovery)).getRecovery()); + } + } + + @Test + public void rejectsInputThatIsNot65Bytes() { + Assertions.assertThrows( + IllegalArgumentException.class, () -> Signature.decode(new byte[64])); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/crypto/secp256k1/SigningServiceTest.java b/src/test/java/org/unicitylabs/sdk/crypto/secp256k1/SigningServiceTest.java index 8ed558f9..d81a8502 100644 --- a/src/test/java/org/unicitylabs/sdk/crypto/secp256k1/SigningServiceTest.java +++ b/src/test/java/org/unicitylabs/sdk/crypto/secp256k1/SigningServiceTest.java @@ -54,7 +54,7 @@ public void testVerifyWithPublicKey() { Signature signature = service.sign(hash); // Verify with public key - boolean isValid = SigningService.verifyWithPublicKey(hash, signature.getBytes(), publicKey); + boolean isValid = SigningService.verify(hash, signature.getBytes(), publicKey); assertTrue(isValid); } diff --git a/src/test/java/org/unicitylabs/sdk/e2e/TokenE2ETest.java b/src/test/java/org/unicitylabs/sdk/e2e/TokenE2ETest.java index e0d45944..50ad68a2 100644 --- a/src/test/java/org/unicitylabs/sdk/e2e/TokenE2ETest.java +++ b/src/test/java/org/unicitylabs/sdk/e2e/TokenE2ETest.java @@ -10,6 +10,8 @@ import org.unicitylabs.sdk.common.CommonTestFlow; import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; +import org.unicitylabs.sdk.transaction.verification.TokenIssuanceVerifierService; +import org.unicitylabs.sdk.transaction.verification.VerificationContext; import java.io.IOException; import java.io.InputStream; @@ -37,9 +39,10 @@ void setUp() throws IOException { this.client = new StateTransitionClient(this.aggregatorClient); try (InputStream stream = getClass().getResourceAsStream("/trust-base.json")) { assertNotNull(stream, "trust-base.json not found"); - this.trustBase = RootTrustBase.fromJson(new String(stream.readAllBytes())); - this.predicateVerifier = PredicateVerifierService.create(); - this.mintJustificationVerifier = new MintJustificationVerifierService(); + this.context = new VerificationContext( + RootTrustBase.fromJson(new String(stream.readAllBytes())), + PredicateVerifierService.create(), new MintJustificationVerifierService(), + new TokenIssuanceVerifierService(false)); } } diff --git a/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java new file mode 100644 index 00000000..fcf588c5 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/functional/CertificationDataBindingTest.java @@ -0,0 +1,92 @@ +package org.unicitylabs.sdk.functional; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.StateTransitionClient; +import org.unicitylabs.sdk.TestAggregatorClient; +import org.unicitylabs.sdk.api.CertificationData; +import org.unicitylabs.sdk.api.CertificationResponse; +import org.unicitylabs.sdk.api.CertificationStatus; +import org.unicitylabs.sdk.api.InclusionProof; +import org.unicitylabs.sdk.api.bft.RootTrustBase; +import org.unicitylabs.sdk.crypto.secp256k1.SigningService; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicateUnlockScript; +import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; +import org.unicitylabs.sdk.transaction.StateMask; +import org.unicitylabs.sdk.transaction.Token; +import org.unicitylabs.sdk.transaction.TransferTransaction; +import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationRule; +import org.unicitylabs.sdk.transaction.verification.InclusionProofVerificationStatus; +import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; +import org.unicitylabs.sdk.transaction.verification.TokenIssuanceVerifierService; +import org.unicitylabs.sdk.transaction.verification.VerificationContext; +import org.unicitylabs.sdk.util.InclusionProofUtils; +import org.unicitylabs.sdk.util.verification.VerificationResult; +import org.unicitylabs.sdk.utils.TokenUtils; + +/** + * M-03: the inclusion-proof rule must bind the certification lock script and source state hash to + * the transaction, not just the transaction hash. A transfer's hash covers only + * {@code (recipient, stateMask, data)}, so two transfers from different source tokens with + * identical recipient/mask/data collide on transaction hash while differing in lock script and + * source state hash — exactly the substitution the binding check must reject. + */ +public class CertificationDataBindingTest { + + @Test + public void rejectsCertificationDataFromADifferentTransaction() throws Exception { + TestAggregatorClient aggregatorClient = TestAggregatorClient.create(); + RootTrustBase trustBase = aggregatorClient.getTrustBase(); + StateTransitionClient client = new StateTransitionClient(aggregatorClient); + PredicateVerifierService predicateVerifier = PredicateVerifierService.create(); + MintJustificationVerifierService mintJustificationVerifier = + new MintJustificationVerifierService(); + VerificationContext context = new VerificationContext(trustBase, predicateVerifier, + mintJustificationVerifier, new TokenIssuanceVerifierService(false)); + + SigningService signingServiceA = SigningService.generate(); + SigningService signingServiceB = SigningService.generate(); + SignaturePredicate ownerA = SignaturePredicate.fromSigningService(signingServiceA); + SignaturePredicate ownerB = SignaturePredicate.fromSigningService(signingServiceB); + + Token tokenA = TokenUtils.mintToken(client, context, ownerA); + Token tokenB = TokenUtils.mintToken(client, context, ownerB); + + // Identical recipient, state mask and data → identical transaction hash across both sources. + SignaturePredicate recipient = SignaturePredicate.fromSigningService(SigningService.generate()); + StateMask stateMask = StateMask.generate(); + + TransferTransaction transferA = TransferTransaction.create(tokenA, recipient, stateMask, null); + TransferTransaction transferB = TransferTransaction.create(tokenB, recipient, stateMask, null); + + Assertions.assertEquals( + transferA.calculateTransactionHash(), transferB.calculateTransactionHash(), + "precondition: the two transfers must share a transaction hash"); + Assertions.assertNotEquals(transferA.getLockScript(), transferB.getLockScript()); + + // Certify transfer A and obtain its inclusion proof (carrying A's certification data). + CertificationResponse response = client.submitCertificationRequest( + CertificationData.fromTransaction( + transferA, + SignaturePredicateUnlockScript.create(transferA, signingServiceA)) + ).get(); + Assertions.assertEquals(CertificationStatus.SUCCESS, response.getStatus()); + + InclusionProof proofA = InclusionProofUtils.waitInclusionProof( + client, trustBase, predicateVerifier, transferA).get(); + + // A's certification data verifies against A... + Assertions.assertEquals( + InclusionProofVerificationStatus.OK, + InclusionProofVerificationRule.verify(trustBase, predicateVerifier, proofA, transferA) + .getStatus()); + + // ...but must be rejected when substituted onto B, which shares the transaction hash but has a + // different lock script and source state hash. + VerificationResult result = + InclusionProofVerificationRule.verify(trustBase, predicateVerifier, proofA, transferB); + Assertions.assertEquals( + InclusionProofVerificationStatus.CERTIFICATION_DATA_MISMATCH, result.getStatus()); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/functional/FunctionalCommonFlowTest.java b/src/test/java/org/unicitylabs/sdk/functional/FunctionalCommonFlowTest.java index 89b3df96..5c1a6a4c 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/FunctionalCommonFlowTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/FunctionalCommonFlowTest.java @@ -6,6 +6,8 @@ import org.unicitylabs.sdk.common.CommonTestFlow; import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; +import org.unicitylabs.sdk.transaction.verification.TokenIssuanceVerifierService; +import org.unicitylabs.sdk.transaction.verification.VerificationContext; public class FunctionalCommonFlowTest extends CommonTestFlow { @@ -13,8 +15,8 @@ public class FunctionalCommonFlowTest extends CommonTestFlow { void setUp() { TestAggregatorClient aggregatorClient = TestAggregatorClient.create(); this.client = new StateTransitionClient(aggregatorClient); - this.trustBase = aggregatorClient.getTrustBase(); - this.predicateVerifier = PredicateVerifierService.create(); - this.mintJustificationVerifier = new MintJustificationVerifierService(); + this.context = new VerificationContext(aggregatorClient.getTrustBase(), + PredicateVerifierService.create(), new MintJustificationVerifierService(), + new TokenIssuanceVerifierService(false)); } } \ No newline at end of file diff --git a/src/test/java/org/unicitylabs/sdk/functional/TokenIssuanceVerifierServiceTest.java b/src/test/java/org/unicitylabs/sdk/functional/TokenIssuanceVerifierServiceTest.java new file mode 100644 index 00000000..b2434161 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/functional/TokenIssuanceVerifierServiceTest.java @@ -0,0 +1,140 @@ +package org.unicitylabs.sdk.functional; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.unicitylabs.sdk.StateTransitionClient; +import org.unicitylabs.sdk.TestAggregatorClient; +import org.unicitylabs.sdk.api.NetworkId; +import org.unicitylabs.sdk.api.bft.RootTrustBase; +import org.unicitylabs.sdk.crypto.secp256k1.SigningService; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; +import org.unicitylabs.sdk.transaction.CertifiedMintTransaction; +import org.unicitylabs.sdk.transaction.Token; +import org.unicitylabs.sdk.transaction.TokenType; +import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; +import org.unicitylabs.sdk.transaction.verification.TokenIssuanceVerifier; +import org.unicitylabs.sdk.transaction.verification.TokenIssuanceVerifierService; +import org.unicitylabs.sdk.transaction.verification.VerificationContext; +import org.unicitylabs.sdk.util.verification.VerificationResult; +import org.unicitylabs.sdk.util.verification.VerificationStatus; +import org.unicitylabs.sdk.utils.TokenUtils; + +/** + * F-02: token issuance policy dispatch. Mirrors the JS {@code TokenIssuanceVerifierServiceTest}. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class TokenIssuanceVerifierServiceTest { + + private RootTrustBase trustBase; + private PredicateVerifierService predicateVerifier; + private TokenType tokenType; + private Token token; + private CertifiedMintTransaction genesis; + + @BeforeAll + public void setupFixture() throws Exception { + TestAggregatorClient aggregatorClient = TestAggregatorClient.create(); + this.trustBase = aggregatorClient.getTrustBase(); + StateTransitionClient client = new StateTransitionClient(aggregatorClient); + this.predicateVerifier = PredicateVerifierService.create(); + + this.tokenType = TokenType.generate(); + VerificationContext context = new VerificationContext( + this.trustBase, + this.predicateVerifier, + new MintJustificationVerifierService(), + new TokenIssuanceVerifierService(false)); + this.token = TokenUtils.mintToken( + client, + context, + SignaturePredicate.fromSigningService(SigningService.generate()), + null, + NetworkId.LOCAL, + this.tokenType + ); + this.genesis = this.token.getGenesis(); + } + + private TokenIssuanceVerifier verifier(VerificationStatus status) { + return new TokenIssuanceVerifier() { + @Override + public TokenType getTokenType() { + return TokenIssuanceVerifierServiceTest.this.tokenType; + } + + @Override + public VerificationResult verify(CertifiedMintTransaction transaction) { + return new VerificationResult<>("Test", status); + } + }; + } + + @Test + public void rejectsUnregisteredTokenTypeByDefault() { + Assertions.assertEquals( + VerificationStatus.FAIL, + new TokenIssuanceVerifierService().verify(this.genesis).getStatus()); + } + + @Test + public void acceptsUnregisteredTokenTypeWhenFailOpen() { + Assertions.assertEquals( + VerificationStatus.OK, + new TokenIssuanceVerifierService(false).verify(this.genesis).getStatus()); + } + + @Test + public void runsTheRegisteredVerifierForATokenType() { + Assertions.assertEquals( + VerificationStatus.OK, + new TokenIssuanceVerifierService(true) + .register(verifier(VerificationStatus.OK)) + .verify(this.genesis) + .getStatus()); + + Assertions.assertEquals( + VerificationStatus.FAIL, + new TokenIssuanceVerifierService() + .register(verifier(VerificationStatus.FAIL)) + .verify(this.genesis) + .getStatus()); + } + + @Test + public void tokenVerifyRejectsAMintWhoseIssuancePolicyFails() { + // A cryptographically valid token whose issuance policy rejects its type must fail Token.verify. + VerificationContext context = new VerificationContext( + this.trustBase, + this.predicateVerifier, + new MintJustificationVerifierService(), + new TokenIssuanceVerifierService().register(verifier(VerificationStatus.FAIL)) + ); + + Assertions.assertEquals( + VerificationStatus.FAIL, this.token.verify(context).getStatus()); + + // The same token passes when the issuance policy accepts it. + VerificationContext accepting = new VerificationContext( + this.trustBase, + this.predicateVerifier, + new MintJustificationVerifierService(), + new TokenIssuanceVerifierService().register(verifier(VerificationStatus.OK)) + ); + Assertions.assertEquals( + VerificationStatus.OK, this.token.verify(accepting).getStatus()); + } + + @Test + public void rejectsDuplicateRegistrationForTheSameTokenType() { + TokenIssuanceVerifierService service = new TokenIssuanceVerifierService() + .register(verifier(VerificationStatus.OK)); + IllegalArgumentException exception = Assertions.assertThrows( + IllegalArgumentException.class, + () -> service.register(verifier(VerificationStatus.OK))); + Assertions.assertTrue(exception.getMessage().contains("Duplicate token issuance verifier"), + exception.getMessage()); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java index 35e99735..f3a8a60b 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitBuilderTest.java @@ -14,23 +14,28 @@ import org.unicitylabs.sdk.payment.TokenSplit; import org.unicitylabs.sdk.payment.asset.Asset; import org.unicitylabs.sdk.payment.asset.AssetId; +import org.unicitylabs.sdk.payment.asset.PaymentAssetCollection; import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; import org.unicitylabs.sdk.predicate.builtin.SignaturePredicateUnlockScript; import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; +import org.unicitylabs.sdk.transaction.StateMask; import org.unicitylabs.sdk.transaction.Token; import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; +import org.unicitylabs.sdk.transaction.verification.TokenIssuanceVerifierService; +import org.unicitylabs.sdk.transaction.verification.VerificationContext; import org.unicitylabs.sdk.util.verification.VerificationStatus; import org.unicitylabs.sdk.utils.TokenUtils; import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; -import java.util.Set; /** * End-to-end functional test for the token split flow: mint a source token, split it, burn the - * source, mint the split output token with the resulting justification, and verify the split - * output through {@link Token#verify}. + * source with the manifest attached, mint the split outputs with the resulting justifications, + * and verify each output through {@link Token#verify}. */ public class SplitBuilderTest { @@ -42,63 +47,147 @@ public void buildAndVerifySplitToken() throws Exception { PredicateVerifierService predicateVerifier = PredicateVerifierService.create(); MintJustificationVerifierService mintJustificationVerifier = new MintJustificationVerifierService(); - mintJustificationVerifier.register(new SplitMintJustificationVerifier( - trustBase, predicateVerifier, TestPaymentData::decode)); + mintJustificationVerifier.register(new SplitMintJustificationVerifier(TestPaymentData::decode)); + VerificationContext context = new VerificationContext(trustBase, predicateVerifier, + mintJustificationVerifier, new TokenIssuanceVerifierService(false)); SigningService signingService = SigningService.generate(); SignaturePredicate ownerPredicate = SignaturePredicate.fromSigningService(signingService); - Set assets = Set.of( - new Asset(new AssetId("ASSET_1".getBytes(StandardCharsets.UTF_8)), BigInteger.valueOf(500)), - new Asset(new AssetId("ASSET_2".getBytes(StandardCharsets.UTF_8)), BigInteger.valueOf(500)) - ); + Asset asset1 = new Asset(new AssetId("ASSET_1".getBytes(StandardCharsets.UTF_8)), BigInteger.valueOf(500)); + Asset asset2 = new Asset(new AssetId("ASSET_2".getBytes(StandardCharsets.UTF_8)), BigInteger.valueOf(500)); Token sourceToken = TokenUtils.mintToken( client, - trustBase, - predicateVerifier, - mintJustificationVerifier, + context, ownerPredicate, - new TestPaymentData(assets).encode() + new TestPaymentData(PaymentAssetCollection.create(asset1, asset2)).encode() ); - SplitResult split = TokenSplit.split( - sourceToken, - TestPaymentData::decode, - List.of(SplitTokenRequest.create(ownerPredicate, assets)) + List requests = List.of( + SplitTokenRequest.create(ownerPredicate, + new TestPaymentData(PaymentAssetCollection.create(asset1))), + SplitTokenRequest.create(ownerPredicate, + new TestPaymentData(PaymentAssetCollection.create(asset2))) ); + SplitResult split = TokenSplit.split(sourceToken, TestPaymentData::decode, requests); + Token burnToken = TokenUtils.transferToken( client, - trustBase, - predicateVerifier, + context, sourceToken, split.getBurnTransaction(), SignaturePredicateUnlockScript.create(split.getBurnTransaction(), signingService) ); - SplitToken splitResult = split.getTokens().get(0); - SplitMintJustification justification = SplitMintJustification.create( - burnToken, - splitResult.getProofs() + List mintedTokens = new ArrayList<>(); + for (SplitToken splitToken : split.getTokens()) { + SplitMintJustification justification = SplitMintJustification.create( + burnToken, splitToken.getProofs()); + + Token minted = TokenUtils.mintToken( + client, + context, + splitToken.getRecipient(), + splitToken.getPaymentData().encode(), + splitToken.getNetworkId(), + splitToken.getTokenType(), + splitToken.getSalt(), + justification.toCbor() + ); + + Assertions.assertEquals( + VerificationStatus.OK, + Token.fromCbor(minted.toCbor()) + .verify(context) + .getStatus() + ); + mintedTokens.add(minted); + } + + // Split the first output again: verifying the second-generation token walks the whole + // provenance chain (split output -> burned split token -> burned source token) iteratively. + Token firstOutput = mintedTokens.get(0); + List secondRequests = List.of( + SplitTokenRequest.create(ownerPredicate, + new TestPaymentData(PaymentAssetCollection.create(asset1))) ); - Token splitToken = TokenUtils.mintToken( + SplitResult secondSplit = TokenSplit.split(firstOutput, TestPaymentData::decode, secondRequests); + + Token secondBurnToken = TokenUtils.transferToken( client, - trustBase, - predicateVerifier, - mintJustificationVerifier, - splitResult.getRecipient(), - new TestPaymentData(assets).encode(), - splitResult.getNetworkId(), - splitResult.getTokenType(), - splitResult.getSalt(), - justification.toCbor() + context, + firstOutput, + secondSplit.getBurnTransaction(), + SignaturePredicateUnlockScript.create(secondSplit.getBurnTransaction(), signingService) + ); + + SplitToken secondSplitToken = secondSplit.getTokens().get(0); + SplitMintJustification secondJustification = SplitMintJustification.create( + secondBurnToken, secondSplitToken.getProofs()); + + Token secondMinted = TokenUtils.mintToken( + client, + context, + secondSplitToken.getRecipient(), + secondSplitToken.getPaymentData().encode(), + secondSplitToken.getNetworkId(), + secondSplitToken.getTokenType(), + secondSplitToken.getSalt(), + secondJustification.toCbor() ); Assertions.assertEquals( VerificationStatus.OK, - splitToken.verify(trustBase, predicateVerifier, mintJustificationVerifier).getStatus() + secondMinted.verify(context).getStatus() ); } -} \ No newline at end of file + + @Test + public void rebuildsByteIdenticalBurnTransactionFromSuppliedStateMask() throws Exception { + TestAggregatorClient aggregatorClient = TestAggregatorClient.create(); + RootTrustBase trustBase = aggregatorClient.getTrustBase(); + StateTransitionClient client = new StateTransitionClient(aggregatorClient); + PredicateVerifierService predicateVerifier = PredicateVerifierService.create(); + + MintJustificationVerifierService mintJustificationVerifier = new MintJustificationVerifierService(); + mintJustificationVerifier.register(new SplitMintJustificationVerifier(TestPaymentData::decode)); + VerificationContext context = new VerificationContext(trustBase, predicateVerifier, + mintJustificationVerifier, new TokenIssuanceVerifierService(false)); + + SigningService signingService = SigningService.generate(); + SignaturePredicate ownerPredicate = SignaturePredicate.fromSigningService(signingService); + + Asset asset = new Asset(new AssetId("ASSET_1".getBytes(StandardCharsets.UTF_8)), BigInteger.valueOf(500)); + + Token token = TokenUtils.mintToken( + client, + context, + ownerPredicate, + new TestPaymentData(PaymentAssetCollection.create(asset)).encode() + ); + + // Identical requests across all calls; only the burn mask determines reproducibility. + List requests = List.of( + SplitTokenRequest.create(ownerPredicate, + new TestPaymentData(PaymentAssetCollection.create(asset))) + ); + StateMask burnStateMask = StateMask.generate(); + + SplitResult first = TokenSplit.split(token, TestPaymentData::decode, requests, burnStateMask); + SplitResult second = TokenSplit.split(token, TestPaymentData::decode, requests, burnStateMask); + SplitResult defaulted = TokenSplit.split(token, TestPaymentData::decode, requests); + + byte[] firstBurn = first.getBurnTransaction().toCbor(); + Assertions.assertArrayEquals(firstBurn, second.getBurnTransaction().toCbor()); + // The default stays random - omitting the mask must not become deterministic. + Assertions.assertFalse(Arrays.equals(firstBurn, defaulted.getBurnTransaction().toCbor())); + + // A mask of the wrong length is a caller bug - the StateMask type rejects it at construction. + Assertions.assertThrows( + IllegalArgumentException.class, + () -> StateMask.fromBytes(new byte[StateMask.MIN_LENGTH - 1])); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java new file mode 100644 index 00000000..511702d8 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitInflationExploitTest.java @@ -0,0 +1,173 @@ +package org.unicitylabs.sdk.functional.payment; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.StateTransitionClient; +import org.unicitylabs.sdk.TestAggregatorClient; +import org.unicitylabs.sdk.api.bft.RootTrustBase; +import org.unicitylabs.sdk.crypto.hash.DataHasher; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.crypto.secp256k1.SigningService; +import org.unicitylabs.sdk.payment.SplitAllocationProof; +import org.unicitylabs.sdk.payment.SplitManifest; +import org.unicitylabs.sdk.payment.SplitMintJustification; +import org.unicitylabs.sdk.payment.SplitMintJustificationVerifier; +import org.unicitylabs.sdk.payment.SplitTokenRequest; +import org.unicitylabs.sdk.payment.asset.Asset; +import org.unicitylabs.sdk.payment.asset.AssetId; +import org.unicitylabs.sdk.payment.asset.PaymentAssetCollection; +import org.unicitylabs.sdk.predicate.EncodedPredicate; +import org.unicitylabs.sdk.predicate.builtin.BurnPredicate; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicateUnlockScript; +import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; +import org.unicitylabs.sdk.smt.radixsum.SparseMerkleSumTree; +import org.unicitylabs.sdk.smt.radixsum.SparseMerkleSumTreeRootNode; +import org.unicitylabs.sdk.transaction.StateMask; +import org.unicitylabs.sdk.transaction.Token; +import org.unicitylabs.sdk.transaction.TokenId; +import org.unicitylabs.sdk.transaction.TransferTransaction; +import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; +import org.unicitylabs.sdk.transaction.verification.TokenIssuanceVerifierService; +import org.unicitylabs.sdk.transaction.verification.VerificationContext; +import org.unicitylabs.sdk.util.verification.VerificationException; +import org.unicitylabs.sdk.utils.TokenUtils; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Regression for the split inflation exploit: a hand-built single-asset split that mirrors + * {@link org.unicitylabs.sdk.payment.TokenSplit#split} but OMITS the conservation check + * ({@code root.value == source value}). This is what a malicious owner produces by assembling the + * burn + justification CBOR manually instead of calling the SDK: the per-asset RSMST is allowed + * to commit a total larger than the burned source amount. The burn itself is a genuine, + * certifiable transfer - only the committed allocation total is a lie. + */ +public class SplitInflationExploitTest { + + @Test + public void mustRejectHolderInflatingReceivedToken() throws Exception { + TestAggregatorClient aggregatorClient = TestAggregatorClient.create(); + RootTrustBase trustBase = aggregatorClient.getTrustBase(); + StateTransitionClient client = new StateTransitionClient(aggregatorClient); + PredicateVerifierService predicateVerifier = PredicateVerifierService.create(); + + MintJustificationVerifierService mintJustificationVerifier = new MintJustificationVerifierService(); + mintJustificationVerifier.register(new SplitMintJustificationVerifier(TestPaymentData::decode)); + VerificationContext context = new VerificationContext(trustBase, predicateVerifier, + mintJustificationVerifier, new TokenIssuanceVerifierService(false)); + + // Issuer mints the token; attacker is a DIFFERENT party who never mints anything. + SigningService issuerSigningService = SigningService.generate(); + SignaturePredicate issuerPredicate = SignaturePredicate.fromSigningService(issuerSigningService); + SigningService attackerSigningService = SigningService.generate(); + SignaturePredicate attackerPredicate = SignaturePredicate.fromSigningService(attackerSigningService); + + // Source token holds a single asset worth 500, owned by the issuer. + Asset asset = new Asset(new AssetId("ASSET_1".getBytes(StandardCharsets.UTF_8)), BigInteger.valueOf(500)); + + Token token = TokenUtils.mintToken( + client, + context, + issuerPredicate, + new TestPaymentData(PaymentAssetCollection.create(asset)).encode() + ); + + // Issuer hands the token over to the attacker. The attacker now legitimately HOLDS a token + // they did not mint - its 500 value is inherited from the issuer, not self-declared. + TransferTransaction handover = TransferTransaction.create( + token, attackerPredicate, StateMask.generate(), null); + token = TokenUtils.transferToken( + client, + context, + token, + handover, + SignaturePredicateUnlockScript.create(handover, issuerSigningService) + ); + + // Attacker splits the RECEIVED token into two outputs, each claiming the FULL 500 -> 1000 from 500. + List requests = List.of( + SplitTokenRequest.create(attackerPredicate, + new TestPaymentData(PaymentAssetCollection.create(new Asset(asset.getId(), BigInteger.valueOf(500))))), + SplitTokenRequest.create(attackerPredicate, + new TestPaymentData(PaymentAssetCollection.create(new Asset(asset.getId(), BigInteger.valueOf(500))))) + ); + + // Hand-built split WITHOUT the conservation check. + SparseMerkleSumTree tree = new SparseMerkleSumTree(HashAlgorithm.SHA256); + for (SplitTokenRequest request : requests) { + TokenId tokenId = TokenId.fromSalt(token.getGenesis().getNetworkId(), request.getSalt()); + EncodedPredicate recipient = EncodedPredicate.fromPredicate(request.getRecipient()); + byte[] data = request.getPaymentData().encode(); + byte[] leafData = SplitMintJustification.calculateLeafData( + token, recipient, request.getSalt(), tokenId, data); + for (Asset requestAsset : request.getPaymentData().getAssets().toList()) { + tree.addLeaf(tokenId.getBytes(), leafData, requestAsset.getValue()); + } + } + + // No root.value == source value check here - that is the omission being exploited. + SparseMerkleSumTreeRootNode root = tree.calculateRoot(); + byte[] manifestBytes = SplitManifest.create(List.of(root.getHash())).toCbor(); + byte[] burnReason = new DataHasher(HashAlgorithm.SHA256).update(manifestBytes).digest().getData(); + TransferTransaction burnTransaction = TransferTransaction.create( + token, BurnPredicate.create(burnReason), StateMask.generate(), manifestBytes); + + // The burn is a genuine, network-certified transfer signed by the attacker (the current owner). + token = TokenUtils.transferToken( + client, + context, + token, + burnTransaction, + SignaturePredicateUnlockScript.create(burnTransaction, attackerSigningService) + ); + + List> proofsPerRequest = new ArrayList<>(); + for (SplitTokenRequest request : requests) { + TokenId tokenId = TokenId.fromSalt(token.getGenesis().getNetworkId(), request.getSalt()); + List proofs = new ArrayList<>(); + for (Asset ignored : request.getPaymentData().getAssets().toList()) { + proofs.add(SplitAllocationProof.create(root, tokenId.getBytes())); + } + proofsPerRequest.add(proofs); + } + + // Conservation: a 500-value token cannot back a 500-value output when the sibling total is + // 1000. The inflated mint must be rejected by genesis verification. + Token burntToken = token; + for (int i = 0; i < requests.size(); i++) { + SplitTokenRequest request = requests.get(i); + SplitMintJustification justification = SplitMintJustification.create( + burntToken, proofsPerRequest.get(i)); + + VerificationException exception = Assertions.assertThrows( + VerificationException.class, + () -> TokenUtils.mintToken( + client, + context, + request.getRecipient(), + request.getPaymentData().encode(), + burntToken.getGenesis().getNetworkId(), + burntToken.getType(), + request.getSalt(), + justification.toCbor() + ) + ); + // The rejection must come from the conservation check, not an incidental error. + Assertions.assertTrue( + containsMessage(exception.getVerificationResult(), "Allocation proof failed"), + () -> "Expected allocation proof failure, got: " + exception.getVerificationResult()); + } + } + + private static boolean containsMessage( + org.unicitylabs.sdk.util.verification.VerificationResult result, String message) { + if (result.getMessage() != null && result.getMessage().contains(message)) { + return true; + } + return result.getResults().stream().anyMatch(r -> containsMessage(r, message)); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitMintJustificationVerifierTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/SplitMintJustificationVerifierTest.java deleted file mode 100644 index 3cac11df..00000000 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/SplitMintJustificationVerifierTest.java +++ /dev/null @@ -1,472 +0,0 @@ -package org.unicitylabs.sdk.functional.payment; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; -import org.unicitylabs.sdk.StateTransitionClient; -import org.unicitylabs.sdk.TestAggregatorClient; -import org.unicitylabs.sdk.api.bft.RootTrustBase; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.crypto.secp256k1.SigningService; -import org.unicitylabs.sdk.payment.PaymentData; -import org.unicitylabs.sdk.payment.SplitMintJustification; -import org.unicitylabs.sdk.payment.SplitMintJustificationVerifier; -import org.unicitylabs.sdk.payment.SplitAssetProof; -import org.unicitylabs.sdk.payment.SplitResult; -import org.unicitylabs.sdk.payment.SplitToken; -import org.unicitylabs.sdk.payment.SplitTokenRequest; -import org.unicitylabs.sdk.payment.TokenSplit; -import org.unicitylabs.sdk.payment.asset.Asset; -import org.unicitylabs.sdk.payment.asset.AssetId; -import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; -import org.unicitylabs.sdk.predicate.builtin.SignaturePredicateUnlockScript; -import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; -import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.smt.plain.SparseMerkleTree; -import org.unicitylabs.sdk.smt.plain.SparseMerkleTreeRootNode; -import org.unicitylabs.sdk.smt.sum.SparseMerkleSumTree; -import org.unicitylabs.sdk.smt.sum.SparseMerkleSumTreeRootNode; -import org.unicitylabs.sdk.transaction.Token; -import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; -import org.unicitylabs.sdk.util.verification.VerificationResult; -import org.unicitylabs.sdk.util.verification.VerificationStatus; -import org.unicitylabs.sdk.utils.TokenUtils; - -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.util.AbstractSet; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * Unit tests for the failure branches of {@link SplitMintJustificationVerifier}. Each test drives - * one specific reject path inside the verifier by handing it a corrupted or mismatched fixture. - * The verifier is invoked directly; integration through the dispatcher and {@link Token#verify} - * is covered by {@link SplitBuilderTest}. - */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -public class SplitMintJustificationVerifierTest { - - private RootTrustBase trustBase; - private PredicateVerifierService predicateVerifier; - private MintJustificationVerifierService mintJustificationVerifier; - private SplitMintJustificationVerifier splitMintJustificationVerifier; - private Asset asset1; - private Asset asset2; - private Token splitToken; - private SplitMintJustification splitJustification; - - @BeforeAll - public void setupFixture() throws Exception { - TestAggregatorClient aggregatorClient = TestAggregatorClient.create(); - this.trustBase = aggregatorClient.getTrustBase(); - - StateTransitionClient client = new StateTransitionClient(aggregatorClient); - this.predicateVerifier = PredicateVerifierService.create(); - - this.splitMintJustificationVerifier = new SplitMintJustificationVerifier( - this.trustBase, this.predicateVerifier, TestPaymentData::decode); - this.mintJustificationVerifier = new MintJustificationVerifierService(); - this.mintJustificationVerifier.register(this.splitMintJustificationVerifier); - - SigningService signingService = SigningService.generate(); - SignaturePredicate ownerPredicate = SignaturePredicate.fromSigningService(signingService); - - this.asset1 = new Asset(new AssetId("ASSET_1".getBytes(StandardCharsets.UTF_8)), BigInteger.valueOf(500)); - this.asset2 = new Asset(new AssetId("ASSET_2".getBytes(StandardCharsets.UTF_8)), BigInteger.valueOf(500)); - - Set assets = Set.of(this.asset1, this.asset2); - - Token sourceToken = TokenUtils.mintToken( - client, - this.trustBase, - this.predicateVerifier, - this.mintJustificationVerifier, - ownerPredicate, - new TestPaymentData(assets).encode() - ); - - SplitResult split = TokenSplit.split( - sourceToken, - TestPaymentData::decode, - List.of(SplitTokenRequest.create(ownerPredicate, assets)) - ); - - Token burnToken = TokenUtils.transferToken( - client, - this.trustBase, - this.predicateVerifier, - sourceToken, - split.getBurnTransaction(), - SignaturePredicateUnlockScript.create(split.getBurnTransaction(), signingService) - ); - - SplitToken splitResult = split.getTokens().get(0); - this.splitJustification = SplitMintJustification.create( - burnToken, - splitResult.getProofs() - ); - - this.splitToken = TokenUtils.mintToken( - client, - this.trustBase, - this.predicateVerifier, - this.mintJustificationVerifier, - splitResult.getRecipient(), - new TestPaymentData(assets).encode(), - splitResult.getNetworkId(), - splitResult.getTokenType(), - splitResult.getSalt(), - this.splitJustification.toCbor() - ); - } - - @Test - public void verifyFailsWhenTransactionIsNull() { - assertNpe("transaction cannot be null", - () -> this.splitMintJustificationVerifier.verify(null, this.mintJustificationVerifier)); - } - - @Test - public void verifyFailsWhenDeserializerIsNull() { - assertNpe("decodePaymentData cannot be null", - () -> new SplitMintJustificationVerifier(this.trustBase, this.predicateVerifier, null)); - } - - @Test - public void verifyFailsWhenTrustBaseIsNull() { - assertNpe("trustBase cannot be null", - () -> new SplitMintJustificationVerifier(null, this.predicateVerifier, TestPaymentData::decode)); - } - - @Test - public void verifyFailsWhenPredicateVerifierIsNull() { - assertNpe("predicateVerifier cannot be null", - () -> new SplitMintJustificationVerifier(this.trustBase, null, TestPaymentData::decode)); - } - - @Test - public void verifyFailsWhenJustificationIsMissing() { - VerificationResult result = verifyWith(null, originalDataBytes()); - assertFailWithMessage(result, "Transaction has no justification."); - } - - @Test - public void verifyFailsWhenAssetsAreMissing() { - VerificationResult result = verifyWithPaymentData( - this.splitJustification.toCbor(), paymentDataOf(null)); - assertFailWithMessage(result, "Assets data is missing."); - } - - @Test - public void verifyFailsWhenBurnTokenVerificationFails() { - byte[] corruptedJustification = corruptBurnTokenInJustification(this.splitJustification.toCbor()); - - VerificationResult result = verifyWith(corruptedJustification, originalDataBytes()); - assertFailWithMessage(result, "Burn token verification failed."); - Assertions.assertFalse(result.getResults().isEmpty()); - } - - @Test - public void verifyFailsWhenAssetAndProofCountsDiffer() { - byte[] data = new TestPaymentData(Set.of(this.asset1)).encode(); - - VerificationResult result = verifyWith(this.splitJustification.toCbor(), data); - assertFailWithMessage(result, "Total amount of assets differ in token and proofs."); - } - - @Test - public void verifyFailsWhenAssetEntryIsNull() { - Set invalidAssets = new NonUniqueAssetSet(Arrays.asList(null, this.asset1)); - - VerificationResult result = verifyWithPaymentData( - this.splitJustification.toCbor(), paymentDataOf(invalidAssets)); - assertFailWithMessage(result, "Asset data is missing."); - } - - @Test - public void verifyFailsWhenAssetIdsAreDuplicated() { - Asset duplicate = new Asset(this.asset1.getId(), this.asset1.getValue().add(BigInteger.ONE)); - Set duplicatedAssets = new NonUniqueAssetSet(List.of(this.asset1, duplicate)); - - VerificationResult result = verifyWithPaymentData( - this.splitJustification.toCbor(), paymentDataOf(duplicatedAssets)); - assertFailWithMessage(result, - String.format("Duplicate asset id %s found in asset data.", this.asset1.getId())); - } - - @Test - public void verifyFailsWhenAggregationPathVerificationFails() throws Exception { - List proofs = new ArrayList<>(this.splitJustification.getProofs()); - SplitAssetProof proof = proofs.get(0); - SparseMerkleTreeRootNode aggregationRoot = new SparseMerkleTree(HashAlgorithm.SHA256).calculateRoot(); - - proofs.set( - 0, - SplitAssetProof.create( - proof.getAssetId(), - aggregationRoot.getPath(proof.getAssetId().toBitString().toBigInteger()), - proof.getAssetTreePath() - ) - ); - - SplitMintJustification mutated = SplitMintJustification.create( - this.splitJustification.getToken(), proofs); - - VerificationResult result = verifyWith(mutated.toCbor(), originalDataBytes()); - assertFailWithMessage(result, - String.format("Aggregation path verification failed for asset: %s", proof.getAssetId())); - } - - @Test - public void verifyFailsWhenAssetTreePathVerificationFails() throws Exception { - List proofs = new ArrayList<>(this.splitJustification.getProofs()); - SplitAssetProof proof = proofs.get(0); - - SparseMerkleSumTreeRootNode assetTreeRoot = new SparseMerkleSumTree(HashAlgorithm.SHA256).calculateRoot(); - - SplitAssetProof mutatedProof = SplitAssetProof.create( - proof.getAssetId(), - proof.getAggregationPath(), - assetTreeRoot.getPath(this.splitToken.getId().toBitString().toBigInteger()) - ); - proofs.set(0, mutatedProof); - - SplitMintJustification mutated = SplitMintJustification.create( - this.splitJustification.getToken(), proofs); - - VerificationResult result = verifyWith(mutated.toCbor(), originalDataBytes()); - assertFailWithMessage(result, - String.format("Asset tree path verification failed for token: %s", this.splitToken.getId())); - } - - @Test - public void verifyFailsWhenProofsUseDifferentAssetTrees() throws Exception { - List proofs = new ArrayList<>( - SplitMintJustification.fromCbor(this.splitJustification.toCbor()).getProofs()); - SplitAssetProof lastProof = proofs.get(proofs.size() - 1); - - SparseMerkleTree aggregationTree = new SparseMerkleTree(HashAlgorithm.SHA256); - aggregationTree.addLeaf( - lastProof.getAssetId().toBitString().toBigInteger(), - lastProof.getAssetTreePath().getRootHash().getImprint() - ); - SparseMerkleTreeRootNode otherAggregationRoot = aggregationTree.calculateRoot(); - - proofs.set(proofs.size() - 1, SplitAssetProof.create( - lastProof.getAssetId(), - otherAggregationRoot.getPath(lastProof.getAssetId().toBitString().toBigInteger()), - lastProof.getAssetTreePath() - )); - - SplitMintJustification mutated = SplitMintJustification.create( - this.splitJustification.getToken(), proofs); - - VerificationResult result = verifyWith(mutated.toCbor(), originalDataBytes()); - assertFailWithMessage(result, "Current proof is not derived from the same asset tree as other proofs."); - } - - @Test - public void verifyFailsWhenAssetTreeRootDoesNotMatchAggregationLeaf() throws Exception { - List proofs = new ArrayList<>(this.splitJustification.getProofs()); - SplitAssetProof proof = proofs.get(0); - - SparseMerkleSumTree assetTree = new SparseMerkleSumTree(HashAlgorithm.SHA256); - assetTree.addLeaf( - this.splitToken.getId().toBitString().toBigInteger(), - new SparseMerkleSumTree.LeafValue( - proof.getAssetId().getBytes(), - proof.getAssetTreePath().getSteps().get(0).getValue().add(BigInteger.ONE) - ) - ); - - SplitAssetProof mutatedProof = SplitAssetProof.create( - proof.getAssetId(), - proof.getAggregationPath(), - assetTree.calculateRoot().getPath(this.splitToken.getId().toBitString().toBigInteger()) - ); - proofs.set(0, mutatedProof); - - SplitMintJustification mutated = SplitMintJustification.create( - this.splitJustification.getToken(), proofs); - - VerificationResult result = verifyWith(mutated.toCbor(), originalDataBytes()); - assertFailWithMessage(result, "Asset tree root does not match aggregation path leaf."); - } - - @Test - public void verifyFailsWhenProofAssetIdIsMissingFromAssetData() { - List proofs = List.of(this.splitJustification.getProofs().get(0)); - PaymentData originalPaymentData = TestPaymentData.decode(originalDataBytes()); - Set assets = originalPaymentData.getAssets().stream() - .filter(asset -> !asset.getId().equals(proofs.get(0).getAssetId())) - .collect(Collectors.toSet()); - - SplitMintJustification mutated = SplitMintJustification.create( - this.splitJustification.getToken(), proofs); - byte[] data = new TestPaymentData(assets).encode(); - - VerificationResult result = verifyWith(mutated.toCbor(), data); - assertFailWithMessage(result, - String.format("Asset id %s not found in asset data.", proofs.get(0).getAssetId())); - } - - @Test - public void verifyFailsWhenAssetAmountDoesNotMatchLeafAmount() { - PaymentData originalPaymentData = TestPaymentData.decode(originalDataBytes()); - List assets = new ArrayList<>(originalPaymentData.getAssets()); - Asset asset = assets.get(0); - Asset modified = new Asset(asset.getId(), asset.getValue().add(BigInteger.ONE)); - assets.set(0, modified); - - byte[] data = new TestPaymentData(Set.copyOf(assets)).encode(); - - VerificationResult result = verifyWith(this.splitJustification.toCbor(), data); - assertFailWithMessage(result, - String.format("Asset amount for asset id %s does not match asset tree leaf.", asset.getId())); - } - - @Test - public void verifyFailsWhenAggregationRootDoesNotMatchBurnPredicate() throws Exception { - List originalProofs = new ArrayList<>(this.splitJustification.getProofs()); - - SparseMerkleTree aggregationTree = new SparseMerkleTree(HashAlgorithm.SHA256); - for (SplitAssetProof proof : originalProofs) { - aggregationTree.addLeaf( - proof.getAssetId().toBitString().toBigInteger(), - proof.getAssetTreePath().getRootHash().getImprint() - ); - } - aggregationTree.addLeaf( - new BigInteger(1, "extra-leaf-marker".getBytes(StandardCharsets.UTF_8)), - new byte[]{0x01} - ); - SparseMerkleTreeRootNode aggregationRoot = aggregationTree.calculateRoot(); - - List mutatedProofs = new ArrayList<>(); - for (SplitAssetProof proof : originalProofs) { - mutatedProofs.add(SplitAssetProof.create( - proof.getAssetId(), - aggregationRoot.getPath(proof.getAssetId().toBitString().toBigInteger()), - proof.getAssetTreePath() - )); - } - - SplitMintJustification mutated = SplitMintJustification.create( - this.splitJustification.getToken(), mutatedProofs); - - VerificationResult result = verifyWith(mutated.toCbor(), originalDataBytes()); - assertFailWithMessage(result, "Aggregation path root does not match burn predicate."); - } - - private byte[] originalDataBytes() { - return this.splitToken.getGenesis().getData().orElseThrow(); - } - - private VerificationResult verifyWith(byte[] justification, byte[] data) { - Token modified = withJustificationAndData(this.splitToken, justification, data); - return this.splitMintJustificationVerifier.verify(modified.getGenesis(), this.mintJustificationVerifier); - } - - private VerificationResult verifyWithPaymentData(byte[] justification, - PaymentData paymentData) { - Token modified = withJustificationAndData(this.splitToken, justification, originalDataBytes()); - SplitMintJustificationVerifier verifier = new SplitMintJustificationVerifier( - this.trustBase, this.predicateVerifier, ignored -> paymentData); - return verifier.verify(modified.getGenesis(), this.mintJustificationVerifier); - } - - private Token withJustificationAndData(Token token, byte[] justification, byte[] data) { - CborDeserializer.CborTag tokenTag = CborDeserializer.decodeTag(token.toCbor()); - List tokenData = CborDeserializer.decodeArray(tokenTag.getData()); - - List certifiedGenesis = CborDeserializer.decodeArray(tokenData.get(1)); - - CborDeserializer.CborTag mintTag = CborDeserializer.decodeTag(certifiedGenesis.get(0)); - List mint = CborDeserializer.decodeArray(mintTag.getData()); - - mint.set(5, CborSerializer.encodeNullable(justification, CborSerializer::encodeByteString)); - mint.set(6, CborSerializer.encodeNullable(data, CborSerializer::encodeByteString)); - - certifiedGenesis.set(0, CborSerializer.encodeTag(mintTag.getTag(), encodeArray(mint))); - tokenData.set(1, encodeArray(certifiedGenesis)); - - return Token.fromCbor(CborSerializer.encodeTag(tokenTag.getTag(), encodeArray(tokenData))); - } - - private byte[] corruptBurnTokenInJustification(byte[] justificationBytes) { - CborDeserializer.CborTag justificationTag = CborDeserializer.decodeTag(justificationBytes); - List reasonData = CborDeserializer.decodeArray(justificationTag.getData()); - - CborDeserializer.CborTag tokenTag = CborDeserializer.decodeTag(reasonData.get(0)); - List tokenData = CborDeserializer.decodeArray(tokenTag.getData()); - List transactions = CborDeserializer.decodeArray(tokenData.get(2)); - List certifiedTransfer = CborDeserializer.decodeArray(transactions.get(0)); - - CborDeserializer.CborTag transferTag = CborDeserializer.decodeTag(certifiedTransfer.get(0)); - List transfer = CborDeserializer.decodeArray(transferTag.getData()); - - byte[] differentStateMask = new byte[32]; - differentStateMask[0] = 1; - transfer.set(2, CborSerializer.encodeByteString(differentStateMask)); - - certifiedTransfer.set(0, CborSerializer.encodeTag(transferTag.getTag(), encodeArray(transfer))); - transactions.set(0, encodeArray(certifiedTransfer)); - tokenData.set(2, encodeArray(transactions)); - reasonData.set(0, CborSerializer.encodeTag(tokenTag.getTag(), encodeArray(tokenData))); - return CborSerializer.encodeTag(justificationTag.getTag(), encodeArray(reasonData)); - } - - private static PaymentData paymentDataOf(Set assets) { - return new PaymentData() { - @Override - public Set getAssets() { - return assets; - } - - @Override - public byte[] encode() { - return new byte[0]; - } - }; - } - - private void assertFailWithMessage(VerificationResult result, String message) { - Assertions.assertEquals(VerificationStatus.FAIL, result.getStatus()); - Assertions.assertEquals(message, result.getMessage()); - } - - private void assertNpe(String message, Runnable callback) { - NullPointerException error = Assertions.assertThrows(NullPointerException.class, callback::run); - Assertions.assertEquals(message, error.getMessage()); - } - - private byte[] encodeArray(List data) { - return CborSerializer.encodeArray(data.toArray(new byte[0][])); - } - - static final class NonUniqueAssetSet extends AbstractSet { - - private final List items; - - NonUniqueAssetSet(List items) { - this.items = new ArrayList<>(items); - } - - @Override - public Iterator iterator() { - return this.items.iterator(); - } - - @Override - public int size() { - return this.items.size(); - } - } -} diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/TestPaymentData.java b/src/test/java/org/unicitylabs/sdk/functional/payment/TestPaymentData.java index 2e670a71..faf6afa2 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/TestPaymentData.java +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/TestPaymentData.java @@ -1,40 +1,27 @@ package org.unicitylabs.sdk.functional.payment; import org.unicitylabs.sdk.payment.PaymentData; -import org.unicitylabs.sdk.payment.asset.Asset; -import org.unicitylabs.sdk.serializer.cbor.CborDeserializer; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; - -import java.util.Set; -import java.util.stream.Collectors; +import org.unicitylabs.sdk.payment.asset.PaymentAssetCollection; public class TestPaymentData implements PaymentData { - private final Set assets; + private final PaymentAssetCollection assets; - public TestPaymentData(Set assets) { - this.assets = Set.copyOf(assets); + public TestPaymentData(PaymentAssetCollection assets) { + this.assets = assets; } @Override - public Set getAssets() { + public PaymentAssetCollection getAssets() { return this.assets; } public static TestPaymentData decode(byte[] bytes) { - Set assets = CborDeserializer.decodeArray(bytes).stream() - .map(Asset::fromCbor) - .collect(Collectors.toSet()); - - return new TestPaymentData(assets); + return new TestPaymentData(PaymentAssetCollection.fromCbor(bytes)); } @Override public byte[] encode() { - return CborSerializer.encodeArray( - this.assets.stream() - .map(Asset::toCbor) - .toArray(byte[][]::new) - ); + return this.assets.toCbor(); } -} \ No newline at end of file +} diff --git a/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java b/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java index cbe31cd4..1029a2e9 100644 --- a/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java +++ b/src/test/java/org/unicitylabs/sdk/functional/payment/TokenSplitTest.java @@ -10,22 +10,27 @@ import org.unicitylabs.sdk.crypto.secp256k1.SigningService; import org.unicitylabs.sdk.payment.SplitMintJustificationVerifier; import org.unicitylabs.sdk.payment.SplitTokenRequest; +import org.unicitylabs.sdk.payment.TokenAssetCountMismatchException; +import org.unicitylabs.sdk.payment.TokenAssetMissingException; +import org.unicitylabs.sdk.payment.TokenAssetValueMismatchException; import org.unicitylabs.sdk.payment.TokenSplit; import org.unicitylabs.sdk.payment.asset.Asset; import org.unicitylabs.sdk.payment.asset.AssetId; +import org.unicitylabs.sdk.payment.asset.PaymentAssetCollection; import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; import org.unicitylabs.sdk.transaction.Token; import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; +import org.unicitylabs.sdk.transaction.verification.TokenIssuanceVerifierService; +import org.unicitylabs.sdk.transaction.verification.VerificationContext; import org.unicitylabs.sdk.utils.TokenUtils; import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.util.List; -import java.util.Set; /** - * Unit tests for the precondition (IAE) branches of {@link TokenSplit#split}. + * Unit tests for the precondition branches of {@link TokenSplit#split}. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class TokenSplitTest { @@ -42,8 +47,9 @@ public void setupFixture() throws Exception { PredicateVerifierService predicateVerifier = PredicateVerifierService.create(); MintJustificationVerifierService mintJustificationVerifier = new MintJustificationVerifierService(); - mintJustificationVerifier.register(new SplitMintJustificationVerifier( - trustBase, predicateVerifier, TestPaymentData::decode)); + mintJustificationVerifier.register(new SplitMintJustificationVerifier(TestPaymentData::decode)); + VerificationContext context = new VerificationContext(trustBase, predicateVerifier, + mintJustificationVerifier, new TokenIssuanceVerifierService(false)); SignaturePredicate ownerPredicate = SignaturePredicate.fromSigningService(SigningService.generate()); @@ -52,40 +58,60 @@ public void setupFixture() throws Exception { this.sourceToken = TokenUtils.mintToken( client, - trustBase, - predicateVerifier, - mintJustificationVerifier, + context, ownerPredicate, - new TestPaymentData(Set.of(this.asset1, this.asset2)).encode() + new TestPaymentData(PaymentAssetCollection.create(this.asset1, this.asset2)).encode() ); } @Test public void splitFailsWhenAssetCountsDiffer() { - IllegalArgumentException exception = Assertions.assertThrows( - IllegalArgumentException.class, + TokenAssetCountMismatchException exception = Assertions.assertThrows( + TokenAssetCountMismatchException.class, () -> TokenSplit.split( this.sourceToken, TestPaymentData::decode, List.of(SplitTokenRequest.create( SignaturePredicate.fromSigningService(SigningService.generate()), - Set.of(this.asset1) + new TestPaymentData(PaymentAssetCollection.create(this.asset1)) )) ) ); Assertions.assertEquals("Token and split tokens asset counts differ.", exception.getMessage()); } + @Test + public void splitFailsWhenAssetIsMissingFromSource() { + Asset unknownAsset = new Asset( + new AssetId("ASSET_3".getBytes(StandardCharsets.UTF_8)), BigInteger.valueOf(400)); + + TokenAssetMissingException exception = Assertions.assertThrows( + TokenAssetMissingException.class, + () -> TokenSplit.split( + this.sourceToken, + TestPaymentData::decode, + List.of(SplitTokenRequest.create( + SignaturePredicate.fromSigningService(SigningService.generate()), + new TestPaymentData(PaymentAssetCollection.create(this.asset1, unknownAsset)) + )) + ) + ); + Assertions.assertEquals( + String.format("Token did not contain asset %s.", unknownAsset.getId()), + exception.getMessage()); + } + @Test public void splitFailsWhenAssetTreeAmountIsLess() { - IllegalArgumentException exception = Assertions.assertThrows( - IllegalArgumentException.class, + TokenAssetValueMismatchException exception = Assertions.assertThrows( + TokenAssetValueMismatchException.class, () -> TokenSplit.split( this.sourceToken, TestPaymentData::decode, List.of(SplitTokenRequest.create( SignaturePredicate.fromSigningService(SigningService.generate()), - Set.of(this.asset1, new Asset(this.asset2.getId(), BigInteger.valueOf(400))) + new TestPaymentData(PaymentAssetCollection.create( + this.asset1, new Asset(this.asset2.getId(), BigInteger.valueOf(400)))) )) ) ); @@ -95,14 +121,15 @@ public void splitFailsWhenAssetTreeAmountIsLess() { @Test public void splitFailsWhenAssetTreeAmountIsMore() { - IllegalArgumentException exception = Assertions.assertThrows( - IllegalArgumentException.class, + TokenAssetValueMismatchException exception = Assertions.assertThrows( + TokenAssetValueMismatchException.class, () -> TokenSplit.split( this.sourceToken, TestPaymentData::decode, List.of(SplitTokenRequest.create( SignaturePredicate.fromSigningService(SigningService.generate()), - Set.of(this.asset1, new Asset(this.asset2.getId(), BigInteger.valueOf(1500))) + new TestPaymentData(PaymentAssetCollection.create( + this.asset1, new Asset(this.asset2.getId(), BigInteger.valueOf(1500)))) )) ) ); diff --git a/src/test/java/org/unicitylabs/sdk/payment/SplitAllocationProofTest.java b/src/test/java/org/unicitylabs/sdk/payment/SplitAllocationProofTest.java new file mode 100644 index 00000000..b9af3302 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/payment/SplitAllocationProofTest.java @@ -0,0 +1,34 @@ +package org.unicitylabs.sdk.payment; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.serializer.cbor.CborSerializationException; +import org.unicitylabs.sdk.serializer.cbor.CborSerializer; + +import java.math.BigInteger; + +public class SplitAllocationProofTest { + + private static byte[] singleSiblingProof(int hashLength) { + byte[] entry = CborSerializer.encodeArray( + CborSerializer.encodeUnsignedInteger(0), + CborSerializer.encodeByteString(new byte[hashLength]), + CborSerializer.encodeBigInteger(BigInteger.ONE)); + return CborSerializer.encodeArray(entry); + } + + @Test + void fromCborRejectsNon32ByteSiblingHash() { + Assertions.assertThrows(CborSerializationException.class, + () -> SplitAllocationProof.fromCbor(singleSiblingProof(31))); + Assertions.assertThrows(CborSerializationException.class, + () -> SplitAllocationProof.fromCbor(singleSiblingProof(33))); + } + + @Test + void fromCborAcceptsSha256SiblingHash() { + Assertions.assertDoesNotThrow( + () -> SplitAllocationProof.fromCbor(singleSiblingProof(HashAlgorithm.SHA256.getLength()))); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifierTest.java b/src/test/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifierTest.java new file mode 100644 index 00000000..dd86c316 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/predicate/builtin/verification/SignaturePredicateVerifierTest.java @@ -0,0 +1,70 @@ +package org.unicitylabs.sdk.predicate.builtin.verification; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.DataHasher; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.crypto.secp256k1.Signature; +import org.unicitylabs.sdk.crypto.secp256k1.SigningService; +import org.unicitylabs.sdk.predicate.EncodedPredicate; +import org.unicitylabs.sdk.predicate.builtin.SignaturePredicate; +import org.unicitylabs.sdk.serializer.cbor.CborSerializer; +import org.unicitylabs.sdk.util.verification.VerificationStatus; + +public class SignaturePredicateVerifierTest { + + private final SignaturePredicateVerifier verifier = new SignaturePredicateVerifier(); + private final SigningService signingService = SigningService.generate(); + private final EncodedPredicate encodedPredicate = EncodedPredicate.fromPredicate( + SignaturePredicate.fromSigningService(this.signingService)); + + private static DataHash hash(byte[] data) { + return new DataHasher(HashAlgorithm.SHA256).update(data).digest(); + } + + private final DataHash sourceStateHash = hash(new byte[]{1}); + private final DataHash transactionHash = hash(new byte[]{2}); + + private Signature signUnlock() { + DataHash digest = hash( + CborSerializer.encodeArray( + CborSerializer.encodeByteString(this.sourceStateHash.getData()), + CborSerializer.encodeByteString(this.transactionHash.getData()) + ) + ); + return this.signingService.sign(digest); + } + + @Test + public void shouldAcceptValidUnlockScript() { + Signature signature = signUnlock(); + + Assertions.assertEquals( + VerificationStatus.OK, + this.verifier.verify(this.encodedPredicate, this.sourceStateHash, this.transactionHash, + signature.encode()).getStatus()); + } + + @Test + public void shouldRejectTamperedRecoveryByte() { + byte[] tampered = signUnlock().encode(); + tampered[64] ^= 1; + + Assertions.assertEquals( + VerificationStatus.FAIL, + this.verifier.verify(this.encodedPredicate, this.sourceStateHash, this.transactionHash, + tampered).getStatus()); + } + + @Test + public void shouldFailWhenRecoveryByteMakesSignatureUnrecoverable() { + byte[] tampered = signUnlock().encode(); + tampered[64] = 2; + + Assertions.assertEquals( + VerificationStatus.FAIL, + this.verifier.verify(this.encodedPredicate, this.sourceStateHash, this.transactionHash, + tampered).getStatus()); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/serializer/cbor/CborDeserializerTest.java b/src/test/java/org/unicitylabs/sdk/serializer/cbor/CborDeserializerTest.java index 95f6d722..ddd4e555 100644 --- a/src/test/java/org/unicitylabs/sdk/serializer/cbor/CborDeserializerTest.java +++ b/src/test/java/org/unicitylabs/sdk/serializer/cbor/CborDeserializerTest.java @@ -167,4 +167,155 @@ void testEncodeTag() { tag.getData() ); } + + @Test + void testReadRawCborStopsAtItemBoundary() { + // [1, [2], 3] - nested item must be read completely and end exactly at its boundary. + List data = CborDeserializer.decodeArray(HexConverter.decode("8301810203")); + + Assertions.assertEquals(3, data.size()); + Assertions.assertArrayEquals(HexConverter.decode("01"), data.get(0)); + Assertions.assertArrayEquals(HexConverter.decode("8102"), data.get(1)); + Assertions.assertArrayEquals(HexConverter.decode("03"), data.get(2)); + } + + @Test + void testReadDeeplyNestedCborWithoutStackOverflow() { + int depth = 100_000; + byte[] bytes = new byte[depth + 1]; + java.util.Arrays.fill(bytes, 0, depth, (byte) 0x81); + bytes[depth] = 0x01; + + Assertions.assertArrayEquals(bytes, CborDeserializer.decodeNullable(bytes, data -> data)); + } + + @Test + void testByteStringLengthOverflowIsRejected() { + // Byte string claiming length 2^32 + 5 followed by 5 bytes: a narrowing (int) cast would + // truncate the length to 5 and parse successfully instead of failing. + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeByteString( + HexConverter.decode("5b00000001000000050000000000")) + ); + + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeTextString( + HexConverter.decode("7b00000001000000050000000000")) + ); + + // Byte string claiming Long.MAX_VALUE bytes. + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeByteString(HexConverter.decode("5b7fffffffffffffff")) + ); + + // Byte string claiming 2^64 - 1 bytes (-1 as a signed long): a signed comparison would + // accept it and the narrowing cast would go negative. + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeByteString(HexConverter.decode("5bffffffffffffffff")) + ); + } + + @Test + void testOversizedCollectionLengthIsRejected() { + // Array claiming 2^32 elements with no data. + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeArray(HexConverter.decode("9b0000000100000000")) + ); + + // Array claiming 2^64 - 1 elements: must fail cleanly, not wrap a counter. + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeArray(HexConverter.decode("9bffffffffffffffff")) + ); + + // Map claiming 2^32 entries with no data. + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeMap(HexConverter.decode("bb0000000100000000")) + ); + + // Nested inside an otherwise valid item: [oversized array]. + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeNullable( + HexConverter.decode("819bffffffffffffffff"), data -> data) + ); + } + + @Test + void testLargeTagIsNotTruncated() { + // Tag 2^33 (does not fit in int): narrowing would silently decode it as 0. + CborTag tag = CborDeserializer.decodeTag(HexConverter.decode("db000000020000000001")); + Assertions.assertEquals(8589934592L, tag.getTag()); + } + + @Test + void testRejectsNonMinimalBigIntegerEncodings() { + // CBOR byte strings: 4105 = [0x05], 40 = [] (zero), 420005 = [0x00, 0x05] (non-minimal). + Assertions.assertEquals( + java.math.BigInteger.valueOf(5), + CborDeserializer.decodeBigInteger(HexConverter.decode("4105")) + ); + Assertions.assertEquals( + java.math.BigInteger.ZERO, + CborDeserializer.decodeBigInteger(HexConverter.decode("40")) + ); + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeBigInteger(HexConverter.decode("420005")) + ); + } + + @Test + void testUnsignedNarrowingAccessors() { + // asInt accepts unsigned 32-bit [0, 2^32); 0xFFFFFFFF comes back as -1 (mask & 0xFFFFFFFFL). + Assertions.assertEquals( + -1, + CborDeserializer.decodeUnsignedInteger(HexConverter.decode("1affffffff")).asInt() + ); + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeUnsignedInteger(HexConverter.decode("1b0000000100000000")) + .asInt() + ); + + // asShort accepts unsigned 16-bit [0, 65536); 0xFFFF comes back as -1 (mask & 0xFFFF). + Assertions.assertEquals( + (short) -1, + CborDeserializer.decodeUnsignedInteger(HexConverter.decode("19ffff")).asShort() + ); + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeUnsignedInteger(HexConverter.decode("1a00010000")).asShort() + ); + + // asByte accepts unsigned 8-bit [0, 256); 0xFF comes back as -1 (mask & 0xFF). + Assertions.assertEquals( + (byte) -1, + CborDeserializer.decodeUnsignedInteger(HexConverter.decode("18ff")).asByte() + ); + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeUnsignedInteger(HexConverter.decode("190100")).asByte() + ); + } + + @Test + void testListSizeIsBoundedToIntRange() { + Assertions.assertEquals( + Integer.MAX_VALUE, + CborDeserializer.decodeUnsignedInteger(HexConverter.decode("1a7fffffff")).asListSize() + ); + // 2^31 does not fit in a valid Java array size. + Assertions.assertThrows( + CborSerializationException.class, + () -> CborDeserializer.decodeUnsignedInteger(HexConverter.decode("1a80000000")) + .asListSize() + ); + } } diff --git a/src/test/java/org/unicitylabs/sdk/smt/CommonPathTest.java b/src/test/java/org/unicitylabs/sdk/smt/CommonPathTest.java deleted file mode 100644 index 650352ae..00000000 --- a/src/test/java/org/unicitylabs/sdk/smt/CommonPathTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.unicitylabs.sdk.smt; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.math.BigInteger; - -public class CommonPathTest { - - @Test - public void shouldCalculateCommonPath() { - Assertions.assertEquals(CommonPath.create( - BigInteger.valueOf(0b11), - BigInteger.valueOf(0b111101111) - ), new CommonPath(BigInteger.valueOf(0b11), 1)); - Assertions.assertEquals(CommonPath.create( - BigInteger.valueOf(0b111101111), - BigInteger.valueOf(0b11) - ), new CommonPath(BigInteger.valueOf(0b11), 1)); - Assertions.assertEquals(CommonPath.create( - BigInteger.valueOf(0b110010000), - BigInteger.valueOf(0b100010000) - ), new CommonPath(BigInteger.valueOf(0b10010000), 7)); - } -} diff --git a/src/test/java/org/unicitylabs/sdk/smt/SparseMerkleTreePathUtilsTest.java b/src/test/java/org/unicitylabs/sdk/smt/SparseMerkleTreePathUtilsTest.java new file mode 100644 index 00000000..81e430cf --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/smt/SparseMerkleTreePathUtilsTest.java @@ -0,0 +1,68 @@ +package org.unicitylabs.sdk.smt; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class SparseMerkleTreePathUtilsTest { + + @Test + public void calculatesCommonPrefixLength() { + byte[] a = new byte[32]; + byte[] b = new byte[32]; + + Assertions.assertEquals(256, SparseMerkleTreePathUtils.commonPrefixLength(a, b, 256)); + Assertions.assertEquals(10, SparseMerkleTreePathUtils.commonPrefixLength(a, b, 10)); + + b[0] = (byte) 0x80; + Assertions.assertEquals(0, SparseMerkleTreePathUtils.commonPrefixLength(a, b, 256)); + + b[0] = (byte) 0x01; + Assertions.assertEquals(7, SparseMerkleTreePathUtils.commonPrefixLength(a, b, 256)); + + b[0] = (byte) 0x00; + b[2] = (byte) 0x01; + Assertions.assertEquals(23, SparseMerkleTreePathUtils.commonPrefixLength(a, b, 256)); + Assertions.assertEquals(10, SparseMerkleTreePathUtils.commonPrefixLength(a, b, 10)); + } + + @Test + public void getBitAtDepthFollowsBigEndianConvention() { + byte[] key = new byte[32]; + key[0] = (byte) 0b1000_0001; + key[31] = (byte) 0x01; + + Assertions.assertEquals(1, SparseMerkleTreePathUtils.getBitAtDepth(key, 0)); + Assertions.assertEquals(0, SparseMerkleTreePathUtils.getBitAtDepth(key, 1)); + Assertions.assertEquals(1, SparseMerkleTreePathUtils.getBitAtDepth(key, 7)); + Assertions.assertEquals(0, SparseMerkleTreePathUtils.getBitAtDepth(key, 254)); + Assertions.assertEquals(1, SparseMerkleTreePathUtils.getBitAtDepth(key, 255)); + } + + @Test + public void getBitAtDepthRejectsOutOfBounds() { + byte[] key = new byte[32]; + Assertions.assertThrows(IllegalArgumentException.class, + () -> SparseMerkleTreePathUtils.getBitAtDepth(key, -1)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> SparseMerkleTreePathUtils.getBitAtDepth(key, 256)); + } + + @Test + public void regionFromKeyPacksPrefixIntoHighOrderBits() { + byte[] key = new byte[32]; + + key[0] = (byte) 0b1010_1111; + byte[] region = SparseMerkleTreePathUtils.regionFromKey(key, 3); + byte[] expected = new byte[32]; + expected[0] = (byte) 0b1010_0000; + Assertions.assertArrayEquals(expected, region); + + key[0] = (byte) 0b1000_0001; + key[1] = (byte) 0b1100_0000; + byte[] spill = SparseMerkleTreePathUtils.regionFromKey(key, 9); + byte[] spillExpected = new byte[32]; + spillExpected[0] = (byte) 0b1000_0001; + spillExpected[1] = (byte) 0b1000_0000; + Assertions.assertArrayEquals(spillExpected, spill); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/smt/plain/MerkleTreePathTest.java b/src/test/java/org/unicitylabs/sdk/smt/plain/MerkleTreePathTest.java deleted file mode 100644 index 76ed7c8e..00000000 --- a/src/test/java/org/unicitylabs/sdk/smt/plain/MerkleTreePathTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import com.fasterxml.jackson.core.JsonProcessingException; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.unicitylabs.sdk.crypto.hash.DataHash; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; -import org.unicitylabs.sdk.smt.MerkleTreePathVerificationResult; -import org.unicitylabs.sdk.util.HexConverter; - -import java.math.BigInteger; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -public class MerkleTreePathTest { - - @Test - public void testConstructorThrowsOnNullArguments() { - Exception exception = assertThrows(NullPointerException.class, - () -> new SparseMerkleTreePath(null, null) - ); - assertEquals("rootHash cannot be null", exception.getMessage()); - exception = assertThrows(NullPointerException.class, - () -> new SparseMerkleTreePath(new DataHash(HashAlgorithm.SHA256, new byte[32]), null) - ); - assertEquals("steps cannot be null", exception.getMessage()); - } - - @Test - public void testShouldVerifyInclusionProof() { - SparseMerkleTreePath path = new SparseMerkleTreePath( - DataHash.fromImprint( - HexConverter.decode( - "0000e9748bbd0c45fc357ffe7c221c7db1ef02f589680d8b0a370b48a669435bde13" - ) - ), - List.of( - new SparseMerkleTreePathStep( - BigInteger.valueOf(69), - HexConverter.decode("76616c756535") - ), - new SparseMerkleTreePathStep( - BigInteger.valueOf(4), - HexConverter.decode( - "8471f8ea3c9a0e50627df4c72d9bd5affbdc12050ee7f4250974ed64949f3b0f" - ) - ), - new SparseMerkleTreePathStep( - BigInteger.valueOf(1), - HexConverter.decode( - "66507538ce0fae31018cfc7b01841b5308e7e44306445710acee947ec4a4b2cd" - ) - ) - ) - ); - - Assertions.assertEquals(new MerkleTreePathVerificationResult(true, true), - path.verify(BigInteger.valueOf(0b100010100))); - Assertions.assertEquals(new MerkleTreePathVerificationResult(true, false), - path.verify(BigInteger.valueOf(0b111))); - } - - @Test - public void testEmptyPathVerification() throws JsonProcessingException { - byte[] cbor = CborSerializer.encodeArray( - DataHash.fromImprint( - HexConverter.decode("00001e54402898172f2948615fb17627733abbd120a85381c624ad060d28321be672") - ).toCbor(), - CborSerializer.encodeArray( - CborSerializer.encodeArray( - CborSerializer.encodeByteString(HexConverter.decode("01")), - CborSerializer.encodeNull() - ), - CborSerializer.encodeArray( - CborSerializer.encodeByteString(HexConverter.decode("01")), - CborSerializer.encodeNull() - ) - ) - ); - - SparseMerkleTreePath path = SparseMerkleTreePath.fromCbor(cbor); - - MerkleTreePathVerificationResult result = path.verify(BigInteger.valueOf(101)); - Assertions.assertTrue(result.isPathValid()); - Assertions.assertFalse(result.isPathIncluded()); - } -} diff --git a/src/test/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreePathFixture.java b/src/test/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreePathFixture.java deleted file mode 100644 index 9d3e9f6b..00000000 --- a/src/test/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreePathFixture.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import org.unicitylabs.sdk.crypto.hash.DataHasher; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; - -import java.util.List; - -public class SparseMerkleTreePathFixture { - - public static SparseMerkleTreePath create() { - return new SparseMerkleTreePath( - new DataHasher(HashAlgorithm.SHA256) - .update(new byte[]{0}) - .update(new byte[]{0}) - .digest(), - List.of() - ); - } - -} diff --git a/src/test/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreeTest.java b/src/test/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreeTest.java deleted file mode 100644 index 3f02344b..00000000 --- a/src/test/java/org/unicitylabs/sdk/smt/plain/SparseMerkleTreeTest.java +++ /dev/null @@ -1,183 +0,0 @@ -package org.unicitylabs.sdk.smt.plain; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.smt.BranchExistsException; -import org.unicitylabs.sdk.smt.LeafOutOfBoundsException; -import org.unicitylabs.sdk.smt.MerkleTreePathVerificationResult; -import org.unicitylabs.sdk.util.HexConverter; - -import java.lang.reflect.Field; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.util.Map; - -public class SparseMerkleTreeTest { - - private final SparseMerkleTreeRootNode root = SparseMerkleTreeRootNode.create( - new PendingNodeBranch( - BigInteger.valueOf(0b10), - new PendingNodeBranch( - BigInteger.valueOf(0b10), - new PendingNodeBranch( - BigInteger.valueOf(0b100), - new PendingLeafBranch( - BigInteger.valueOf(0b10000), - HexConverter.decode("76616c75653030303030303030") - ), - new PendingNodeBranch( - BigInteger.valueOf(0b1001), - new PendingLeafBranch( - BigInteger.valueOf(0b10), - HexConverter.decode("76616c75653030303130303030") - ), - new PendingLeafBranch( - BigInteger.valueOf(0b11), - HexConverter.decode("76616c75653030303130303030") - ) - ) - ), - new PendingLeafBranch( - BigInteger.valueOf(0b11), - HexConverter.decode("76616c7565313030") - ) - ), - new PendingLeafBranch( - BigInteger.valueOf(0b1000101), - HexConverter.decode("76616c756530303031303130") - ) - ).finalize(HashAlgorithm.SHA256), - new PendingNodeBranch( - BigInteger.valueOf(0b11), - new PendingNodeBranch( - BigInteger.valueOf(0b1010), - new PendingLeafBranch( - BigInteger.valueOf(0b11110), - HexConverter.decode("76616c75653131313030313031") - ), - new PendingLeafBranch( - BigInteger.valueOf(0b1101), - HexConverter.decode("76616c756531303130313031") - ) - ), - new PendingNodeBranch( - BigInteger.valueOf(0b11), - new PendingLeafBranch( - BigInteger.valueOf(0b10), - HexConverter.decode("76616c7565303131") - ), - new PendingLeafBranch( - BigInteger.valueOf(0b1111011), - HexConverter.decode("76616c75653131313031313131") - ) - ) - ).finalize(HashAlgorithm.SHA256), - HashAlgorithm.SHA256 - ); - - @Test - public void treeShouldBeHalfCalculated() throws Exception { - SparseMerkleTree smt = new SparseMerkleTree(HashAlgorithm.SHA256); - - smt.addLeaf(BigInteger.valueOf(0b10), new byte[]{1, 2, 3}); - smt.calculateRoot(); - smt.addLeaf(BigInteger.valueOf(0b11), new byte[]{1, 2, 3, 4}); - - FinalizedLeafBranch left = new PendingLeafBranch(BigInteger.valueOf(2), - new byte[]{1, 2, 3}).finalize(HashAlgorithm.SHA256); - PendingLeafBranch right = new PendingLeafBranch(BigInteger.valueOf(3), new byte[]{1, 2, 3, 4}); - - Field leftField = SparseMerkleTree.class.getDeclaredField("left"); - leftField.setAccessible(true); - Field rightField = SparseMerkleTree.class.getDeclaredField("right"); - rightField.setAccessible(true); - - Assertions.assertEquals(left, leftField.get(smt)); - Assertions.assertEquals(right, rightField.get(smt)); - } - - @Test - public void shouldVerifyTheTree() throws Exception { - SparseMerkleTree smt = new SparseMerkleTree(HashAlgorithm.SHA256); - Map leaves = Map.ofEntries( - Map.entry(0b110010000, "value00010000"), - Map.entry(0b100000000, "value00000000"), - Map.entry(0b100010000, "value00010000"), - Map.entry(0b111100101, "value11100101"), - Map.entry(0b1100, "value100"), - Map.entry(0b1011, "value011"), - Map.entry(0b111101111, "value11101111"), - Map.entry(0b10001010, "value0001010"), - Map.entry(0b11010101, "value1010101") - ); - for (Map.Entry leaf : leaves.entrySet()) { - smt.addLeaf(BigInteger.valueOf(leaf.getKey()), - leaf.getValue().getBytes(StandardCharsets.UTF_8)); - } - - Assertions.assertThrows(BranchExistsException.class, () -> - smt.addLeaf(BigInteger.valueOf(0b10000000), "OnPath".getBytes(StandardCharsets.UTF_8)) - ); - - Assertions.assertThrows(LeafOutOfBoundsException.class, () -> - smt.addLeaf(BigInteger.valueOf(0b1000000000), - "ThroughLeaf".getBytes(StandardCharsets.UTF_8)) - ); - - Assertions.assertEquals(smt.calculateRoot(), this.root); - } - - @Test - public void shouldGetWorkingPath() throws Exception { - SparseMerkleTree smt = new SparseMerkleTree(HashAlgorithm.SHA256); - Map leaves = Map.ofEntries( - Map.entry(0b110010000, "value00010000"), - Map.entry(0b100000000, "value00000000"), - Map.entry(0b100010000, "value00010000"), - Map.entry(0b111100101, "value11100101"), - Map.entry(0b1100, "value100"), - Map.entry(0b1011, "value011"), - Map.entry(0b111101111, "value11101111"), - Map.entry(0b10001010, "value0001010"), - Map.entry(0b11010101, "value1010101") - ); - for (Map.Entry leaf : leaves.entrySet()) { - smt.addLeaf(BigInteger.valueOf(leaf.getKey()), - leaf.getValue().getBytes(StandardCharsets.UTF_8)); - } - SparseMerkleTreeRootNode root = smt.calculateRoot(); - - SparseMerkleTreePath path = root.getPath(BigInteger.valueOf(0b11010)); - MerkleTreePathVerificationResult result = path.verify(BigInteger.valueOf(0b11010)); - Assertions.assertFalse(result.isPathIncluded()); - Assertions.assertTrue(result.isPathValid()); - Assertions.assertFalse(result.isSuccessful()); - - path = root.getPath(BigInteger.valueOf(0b110010000)); - result = path.verify(BigInteger.valueOf(0b110010000)); - Assertions.assertTrue(result.isPathIncluded()); - Assertions.assertTrue(result.isPathValid()); - Assertions.assertTrue(result.isSuccessful()); - - path = root.getPath(BigInteger.valueOf(0b110010000)); - result = path.verify(BigInteger.valueOf(0b11010)); - Assertions.assertFalse(result.isPathIncluded()); - Assertions.assertTrue(result.isPathValid()); - Assertions.assertFalse(result.isSuccessful()); - - path = root.getPath(BigInteger.valueOf(0b111100101)); - result = path.verify(BigInteger.valueOf(0b111100101)); - Assertions.assertTrue(result.isPathIncluded()); - Assertions.assertTrue(result.isPathValid()); - Assertions.assertTrue(result.isSuccessful()); - - SparseMerkleTree emptyTree = new SparseMerkleTree(HashAlgorithm.SHA256); - SparseMerkleTreeRootNode emptyRoot = emptyTree.calculateRoot(); - path = emptyRoot.getPath(BigInteger.valueOf(0b100)); - result = path.verify(BigInteger.valueOf(0b10)); - Assertions.assertFalse(result.isPathIncluded()); - Assertions.assertTrue(result.isPathValid()); - Assertions.assertFalse(result.isSuccessful()); - } -} diff --git a/src/test/java/org/unicitylabs/sdk/smt/radix/SparseMerkleTreeTest.java b/src/test/java/org/unicitylabs/sdk/smt/radix/SparseMerkleTreeTest.java new file mode 100644 index 00000000..b0ff54b5 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/smt/radix/SparseMerkleTreeTest.java @@ -0,0 +1,76 @@ +package org.unicitylabs.sdk.smt.radix; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.api.InclusionCertificate; +import org.unicitylabs.sdk.api.StateId; +import org.unicitylabs.sdk.crypto.hash.DataHash; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.serializer.cbor.CborSerializer; + +public class SparseMerkleTreeTest { + + @Test + void addLeafRejectsNon32ByteKeyButAcceptsAnyData() throws Exception { + SparseMerkleTree tree = new SparseMerkleTree(HashAlgorithm.SHA256); + Assertions.assertThrows(IllegalArgumentException.class, + () -> tree.addLeaf(new byte[31], new byte[32])); + Assertions.assertThrows(NullPointerException.class, + () -> tree.addLeaf(null, new byte[32])); + + Assertions.assertDoesNotThrow(() -> tree.addLeaf(new byte[32], new byte[31])); + byte[] otherKey = new byte[32]; + otherKey[0] = 1; + Assertions.assertDoesNotThrow(() -> tree.addLeaf(otherKey, new byte[100])); + } + + @Test + void deepSplitAtDepth255VerifiesWithRegion() throws Exception { + SparseMerkleTree tree = new SparseMerkleTree(HashAlgorithm.SHA256); + + byte[] a = new byte[32]; + byte[] b = new byte[32]; + b[31] = (byte) 0x80; + byte[] valueA = new byte[32]; + valueA[0] = 1; + byte[] valueB = new byte[32]; + valueB[0] = 2; + + tree.addLeaf(a, valueA); + tree.addLeaf(b, valueB); + SparseMerkleTreeRootNode root = tree.calculateRoot(); + + for (byte[][] entry : new byte[][][]{{a, valueA}, {b, valueB}}) { + InclusionCertificate certificate = InclusionCertificate.create(root, entry[0]); + StateId key = StateId.fromCbor(CborSerializer.encodeByteString(entry[0])); + DataHash value = new DataHash(HashAlgorithm.SHA256, entry[1]); + Assertions.assertTrue(certificate.verify(key, value, root.getHash())); + } + } + + @Test + void everyLeafVerifiesThroughNonZeroRegions() throws Exception { + int[] firstBytes = {0b10010000, 0b00000000, 0b00010000, 0b10000000, 0b01100000, 0b00010100}; + byte[][] keys = new byte[firstBytes.length][]; + byte[][] values = new byte[firstBytes.length][]; + SparseMerkleTree tree = new SparseMerkleTree(HashAlgorithm.SHA256); + for (int i = 0; i < firstBytes.length; i++) { + byte[] key = new byte[32]; + key[0] = (byte) firstBytes[i]; + byte[] value = new byte[32]; + value[0] = (byte) (i + 1); + keys[i] = key; + values[i] = value; + tree.addLeaf(key, value); + } + + SparseMerkleTreeRootNode root = tree.calculateRoot(); + + for (int i = 0; i < keys.length; i++) { + InclusionCertificate certificate = InclusionCertificate.create(root, keys[i]); + StateId key = StateId.fromCbor(CborSerializer.encodeByteString(keys[i])); + DataHash value = new DataHash(HashAlgorithm.SHA256, values[i]); + Assertions.assertTrue(certificate.verify(key, value, root.getHash())); + } + } +} diff --git a/src/test/java/org/unicitylabs/sdk/smt/radixsum/SparseMerkleSumTreeTest.java b/src/test/java/org/unicitylabs/sdk/smt/radixsum/SparseMerkleSumTreeTest.java new file mode 100644 index 00000000..8eb89d9f --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/smt/radixsum/SparseMerkleSumTreeTest.java @@ -0,0 +1,118 @@ +package org.unicitylabs.sdk.smt.radixsum; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; +import org.unicitylabs.sdk.payment.SplitAllocationProof; +import org.unicitylabs.sdk.smt.LeafExistsException; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.List; + +/** + * Radix sparse Merkle sum tree tests, mirroring the JS SDK suite. + */ +public class SparseMerkleSumTreeTest { + + private static byte[] key(int... bytes) { + byte[] key = new byte[32]; + for (int i = 0; i < bytes.length; i++) { + key[i] = (byte) bytes[i]; + } + return key; + } + + private static byte[] data(int seed) { + byte[] data = new byte[32]; + Arrays.fill(data, (byte) seed); + return data; + } + + private static final List LEAVES = List.of( + new Leaf(data(1), key(0b10010000), BigInteger.valueOf(5)), + new Leaf(data(2), key(0b00000000), BigInteger.valueOf(10)), + new Leaf(data(3), key(0b00010000), BigInteger.valueOf(20)), + new Leaf(data(4), key(0b10000000), BigInteger.valueOf(40)), + new Leaf(data(5), key(0b01100000), BigInteger.valueOf(80)), + new Leaf(data(6), key(0b00010100), BigInteger.valueOf(160)) + ); + + private static SparseMerkleSumTree build(List leaves) throws Exception { + SparseMerkleSumTree tree = new SparseMerkleSumTree(HashAlgorithm.SHA256); + for (Leaf leaf : leaves) { + tree.addLeaf(leaf.key, leaf.data, leaf.value); + } + return tree; + } + + @Test + public void reconstructsRootSumAndVerifiesEveryLeaf() throws Exception { + SparseMerkleSumTreeRootNode root = build(LEAVES).calculateRoot(); + BigInteger total = LEAVES.stream().map(leaf -> leaf.value) + .reduce(BigInteger.ZERO, BigInteger::add); + Assertions.assertEquals(total, root.getValue()); + + for (Leaf leaf : LEAVES) { + SplitAllocationProof proof = SplitAllocationProof.create(root, leaf.key); + Assertions.assertEquals(total, + proof.calculateRoot(leaf.key, leaf.data, leaf.value).getSum()); + Assertions.assertTrue(proof.verify(leaf.key, leaf.data, leaf.value, root.getHash(), total)); + } + } + + @Test + public void rejectsTamperedLeafAmount() throws Exception { + SparseMerkleSumTreeRootNode root = build(LEAVES).calculateRoot(); + Leaf leaf = LEAVES.get(0); + SplitAllocationProof proof = SplitAllocationProof.create(root, leaf.key); + Assertions.assertFalse(proof.verify( + leaf.key, leaf.data, leaf.value.add(BigInteger.ONE), root.getHash(), leaf.value)); + } + + @Test + public void rejectsDuplicateKey() throws Exception { + Leaf leaf = LEAVES.get(0); + SparseMerkleSumTree tree = build(List.of(leaf)); + LeafExistsException exception = Assertions.assertThrows(LeafExistsException.class, + () -> tree.addLeaf(leaf.key, leaf.data, leaf.value)); + Assertions.assertEquals("Leaf already exists.", exception.getMessage()); + } + + @Test + public void producesEmptyProofForSingleLeafTree() throws Exception { + Leaf leaf = LEAVES.get(0); + SparseMerkleSumTreeRootNode root = build(List.of(leaf)).calculateRoot(); + SplitAllocationProof proof = SplitAllocationProof.create(root, leaf.key); + Assertions.assertEquals(0, proof.getLength()); + Assertions.assertEquals(leaf.value, root.getValue()); + + Assertions.assertEquals(leaf.value, + proof.calculateRoot(leaf.key, leaf.data, leaf.value).getSum()); + Assertions.assertTrue( + proof.verify(leaf.key, leaf.data, leaf.value, root.getHash(), leaf.value)); + } + + @Test + public void survivesCborRoundTripOfInclusionProof() throws Exception { + SparseMerkleSumTreeRootNode root = build(LEAVES).calculateRoot(); + BigInteger total = LEAVES.stream().map(leaf -> leaf.value) + .reduce(BigInteger.ZERO, BigInteger::add); + Leaf leaf = LEAVES.get(4); + SplitAllocationProof proof = SplitAllocationProof.create(root, leaf.key); + SplitAllocationProof decoded = SplitAllocationProof.fromCbor(proof.toCbor()); + Assertions.assertTrue(decoded.verify(leaf.key, leaf.data, leaf.value, root.getHash(), total)); + } + + private static final class Leaf { + private final byte[] data; + private final byte[] key; + private final BigInteger value; + + private Leaf(byte[] data, byte[] key, BigInteger value) { + this.data = data; + this.key = key; + this.value = value; + } + } +} diff --git a/src/test/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTreeTest.java b/src/test/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTreeTest.java deleted file mode 100644 index 2dfc635b..00000000 --- a/src/test/java/org/unicitylabs/sdk/smt/sum/SparseMerkleSumTreeTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.unicitylabs.sdk.smt.sum; - - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.unicitylabs.sdk.crypto.hash.HashAlgorithm; -import org.unicitylabs.sdk.smt.MerkleTreePathVerificationResult; -import org.unicitylabs.sdk.smt.sum.SparseMerkleSumTree.LeafValue; -import org.unicitylabs.sdk.util.HexConverter; - -import java.math.BigInteger; -import java.util.Map; -import java.util.Map.Entry; - -class SparseMerkleSumTreeTest { - - @Test - void shouldAgreeWithSpecExamples() throws Exception { - SparseMerkleSumTree treeLeftOnly = new SparseMerkleSumTree(HashAlgorithm.SHA256); - treeLeftOnly.addLeaf(new BigInteger("100", 2), new LeafValue("a".getBytes(), BigInteger.valueOf(1))); - SparseMerkleSumTreeRootNode rootLeftOnly = treeLeftOnly.calculateRoot(); - Assertions.assertEquals(BigInteger.valueOf(1), rootLeftOnly.getValue()); - Assertions.assertArrayEquals(HexConverter.decode("5822" + "0000" + "34e0cf342d70c0d10e3ba481f72db532ecfd723afa3c25812a4bef61b5198d0b"), rootLeftOnly.getRootHash().toCbor()); - - SparseMerkleSumTree treeRightOnly = new SparseMerkleSumTree(HashAlgorithm.SHA256); - treeRightOnly.addLeaf(new BigInteger("111", 2), new LeafValue("b".getBytes(), BigInteger.valueOf(2))); - SparseMerkleSumTreeRootNode rootRightOnly = treeRightOnly.calculateRoot(); - Assertions.assertEquals(BigInteger.valueOf(2), rootRightOnly.getValue()); - Assertions.assertArrayEquals(HexConverter.decode("5822" + "0000" + "da47d1cda8dab5159b2bed1ea27c3d24ed990989fac3c62ace05273fea51f958"), rootRightOnly.getRootHash().toCbor()); - - SparseMerkleSumTree treeFourLeaves = new SparseMerkleSumTree(HashAlgorithm.SHA256); - treeFourLeaves.addLeaf(new BigInteger("1000", 2), new LeafValue("a".getBytes(), BigInteger.valueOf(1))); - treeFourLeaves.addLeaf(new BigInteger("1100", 2), new LeafValue("b".getBytes(), BigInteger.valueOf(2))); - treeFourLeaves.addLeaf(new BigInteger("1011", 2), new LeafValue("c".getBytes(), BigInteger.valueOf(3))); - treeFourLeaves.addLeaf(new BigInteger("1111", 2), new LeafValue("d".getBytes(), BigInteger.valueOf(4))); - SparseMerkleSumTreeRootNode rootFourLeaves = treeFourLeaves.calculateRoot(); - Assertions.assertEquals(BigInteger.valueOf(10), rootFourLeaves.getValue()); - Assertions.assertArrayEquals(HexConverter.decode("5822" + "0000" + "adfefa7c86b18d1216eece9fe0ce82ca58fd8cf482305c3c4e1a0a1361dc9d15"), rootFourLeaves.getRootHash().toCbor()); - } - - @Test - void shouldBuildTreeWithNumericValues() throws Exception { - Map leaves = Map.of( - new BigInteger("1000", 2), new LeafValue("left-1".getBytes(), BigInteger.valueOf(10)), - new BigInteger("1001", 2), new LeafValue("right-1".getBytes(), BigInteger.valueOf(20)), - new BigInteger("1010", 2), new LeafValue("left-2".getBytes(), BigInteger.valueOf(30)), - new BigInteger("1011", 2), new LeafValue("right-2".getBytes(), BigInteger.valueOf(40)) - ); - - SparseMerkleSumTree tree = new SparseMerkleSumTree(HashAlgorithm.SHA256); - for (Entry entry : leaves.entrySet()) { - tree.addLeaf(entry.getKey(), entry.getValue()); - } - - SparseMerkleSumTreeRootNode root = tree.calculateRoot(); - Assertions.assertEquals(BigInteger.valueOf(100), root.getValue()); - - for (Entry entry : leaves.entrySet()) { - SparseMerkleSumTreePath path = root.getPath(entry.getKey()); - MerkleTreePathVerificationResult verificationResult = path.verify(entry.getKey()); - Assertions.assertTrue(verificationResult.isPathIncluded()); - Assertions.assertTrue(verificationResult.isPathValid()); - Assertions.assertTrue(verificationResult.isSuccessful()); - - Assertions.assertEquals(root.getRootHash(), path.getRootHash()); - Assertions.assertArrayEquals( - entry.getValue().getValue(), - path.getSteps().get(0).getData().orElse(null) - ); - Assertions.assertEquals( - entry.getValue().getCounter(), - path.getSteps().get(0).getValue() - ); - } - - tree.addLeaf(new BigInteger("1110", 2), new LeafValue(new byte[32], BigInteger.valueOf(100))); - root = tree.calculateRoot(); - Assertions.assertEquals(BigInteger.valueOf(200), root.getValue()); - } - - @Test - void shouldThrowErrorOnNonPositivePathOrSum() { - SparseMerkleSumTree tree = new SparseMerkleSumTree(HashAlgorithm.SHA256); - Assertions.assertThrows(IllegalArgumentException.class, - () -> tree.addLeaf(BigInteger.valueOf(-1), - new LeafValue(new byte[32], BigInteger.valueOf(100)))); - Assertions.assertThrows(IllegalArgumentException.class, - () -> tree.addLeaf(BigInteger.ONE, new LeafValue(new byte[32], BigInteger.valueOf(-1)))); - } -} diff --git a/src/test/java/org/unicitylabs/sdk/transaction/StateMaskTest.java b/src/test/java/org/unicitylabs/sdk/transaction/StateMaskTest.java new file mode 100644 index 00000000..07580cab --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/transaction/StateMaskTest.java @@ -0,0 +1,29 @@ +package org.unicitylabs.sdk.transaction; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class StateMaskTest { + + @Test + void rejectsMasksOutsideAllowedRange() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> StateMask.fromBytes(new byte[StateMask.MIN_LENGTH - 1])); + Assertions.assertThrows(IllegalArgumentException.class, + () -> StateMask.fromBytes(new byte[StateMask.MAX_LENGTH + 1])); + } + + @Test + void acceptsMasksWithinAllowedRange() { + for (int length : new int[] {StateMask.MIN_LENGTH, StateMask.LENGTH, StateMask.MAX_LENGTH}) { + StateMask mask = StateMask.fromBytes(new byte[length]); + Assertions.assertEquals(mask, StateMask.fromCbor(mask.toCbor())); + Assertions.assertEquals(length, mask.getBytes().length); + } + } + + @Test + void generatesDefaultLengthMask() { + Assertions.assertEquals(StateMask.LENGTH, StateMask.generate().getBytes().length); + } +} diff --git a/src/test/java/org/unicitylabs/sdk/transaction/TokenTypeTest.java b/src/test/java/org/unicitylabs/sdk/transaction/TokenTypeTest.java new file mode 100644 index 00000000..c7557437 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/transaction/TokenTypeTest.java @@ -0,0 +1,23 @@ +package org.unicitylabs.sdk.transaction; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TokenTypeTest { + + @Test + void rejectsTypesOutsideAllowedRange() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new TokenType(new byte[TokenType.MIN_LENGTH - 1])); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new TokenType(new byte[TokenType.MAX_LENGTH + 1])); + } + + @Test + void acceptsTypesWithinAllowedRange() { + for (int length : new int[] {TokenType.MIN_LENGTH, 32, TokenType.MAX_LENGTH}) { + TokenType type = new TokenType(new byte[length]); + Assertions.assertEquals(type, TokenType.fromCbor(type.toCbor())); + } + } +} diff --git a/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java b/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java index c10f9cf5..8692af3f 100644 --- a/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java +++ b/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java @@ -6,19 +6,15 @@ import org.unicitylabs.sdk.api.CertificationResponse; import org.unicitylabs.sdk.api.CertificationStatus; import org.unicitylabs.sdk.api.NetworkId; -import org.unicitylabs.sdk.api.bft.RootTrustBase; import org.unicitylabs.sdk.crypto.secp256k1.SigningService; import org.unicitylabs.sdk.predicate.Predicate; import org.unicitylabs.sdk.predicate.UnlockScript; import org.unicitylabs.sdk.predicate.builtin.SignaturePredicateUnlockScript; -import org.unicitylabs.sdk.predicate.verification.PredicateVerifierService; -import org.unicitylabs.sdk.serializer.cbor.CborSerializer; import org.unicitylabs.sdk.transaction.*; -import org.unicitylabs.sdk.transaction.verification.MintJustificationVerifierService; +import org.unicitylabs.sdk.transaction.verification.VerificationContext; import org.unicitylabs.sdk.util.InclusionProofUtils; import org.unicitylabs.sdk.util.verification.VerificationStatus; -import java.security.SecureRandom; /** * Test helpers for minting and transferring certified tokens. @@ -27,74 +23,58 @@ public class TokenUtils { public static Token mintToken( StateTransitionClient client, - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - MintJustificationVerifierService mintJustificationVerifier, + VerificationContext context, Predicate recipient ) throws Exception { - return TokenUtils.mintToken(client, trustBase, predicateVerifier, mintJustificationVerifier, - recipient, (byte[]) null); + return TokenUtils.mintToken(client, context, recipient, (byte[]) null); } public static Token mintToken( StateTransitionClient client, - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - MintJustificationVerifierService mintJustificationVerifier, + VerificationContext context, Predicate recipient, byte[] data ) throws Exception { - return TokenUtils.mintToken(client, trustBase, predicateVerifier, mintJustificationVerifier, - recipient, data, NetworkId.LOCAL); + return TokenUtils.mintToken(client, context, recipient, data, NetworkId.LOCAL); } public static Token mintToken( StateTransitionClient client, - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - MintJustificationVerifierService mintJustificationVerifier, + VerificationContext context, Predicate recipient, byte[] data, NetworkId networkId ) throws Exception { - return TokenUtils.mintToken(client, trustBase, predicateVerifier, mintJustificationVerifier, - recipient, data, networkId, TokenType.generate()); + return TokenUtils.mintToken(client, context, recipient, data, networkId, TokenType.generate()); } public static Token mintToken( StateTransitionClient client, - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - MintJustificationVerifierService mintJustificationVerifier, + VerificationContext context, Predicate recipient, byte[] data, NetworkId networkId, TokenType tokenType ) throws Exception { - return TokenUtils.mintToken(client, trustBase, predicateVerifier, mintJustificationVerifier, - recipient, data, networkId, tokenType, TokenSalt.generate()); + return TokenUtils.mintToken(client, context, recipient, data, networkId, tokenType, + TokenSalt.generate()); } public static Token mintToken( StateTransitionClient client, - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - MintJustificationVerifierService mintJustificationVerifier, + VerificationContext context, Predicate recipient, byte[] data, NetworkId networkId, TokenType tokenType, TokenSalt salt ) throws Exception { - return TokenUtils.mintToken(client, trustBase, predicateVerifier, mintJustificationVerifier, - recipient, data, networkId, tokenType, salt, null); + return TokenUtils.mintToken(client, context, recipient, data, networkId, tokenType, salt, null); } public static Token mintToken( StateTransitionClient client, - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - MintJustificationVerifierService mintJustificationVerifier, + VerificationContext context, Predicate recipient, byte[] data, NetworkId networkId, @@ -120,14 +100,14 @@ public static Token mintToken( } return Token.mint( - trustBase, - predicateVerifier, - mintJustificationVerifier, transaction.toCertifiedTransaction( - trustBase, - predicateVerifier, - InclusionProofUtils.waitInclusionProof(client, trustBase, predicateVerifier, transaction).get() - ) + context.getTrustBase(), + context.getPredicateVerifier(), + InclusionProofUtils.waitInclusionProof( + client, context.getTrustBase(), context.getPredicateVerifier(), + transaction).get() + ), + context ); } @@ -136,8 +116,7 @@ public static Token mintToken( * Deserialize token, build transfer transaction and submit certified transfer. * * @param client state transition client - * @param trustBase trust base - * @param predicateVerifier predicate verifier + * @param context verification context * @param tokenBytes serialized token bytes * @param recipient recipient address * @param signingService sender signing service @@ -148,30 +127,24 @@ public static Token mintToken( */ public static Token transferToken( StateTransitionClient client, - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, - MintJustificationVerifierService mintJustificationVerifier, + VerificationContext context, byte[] tokenBytes, Predicate recipient, SigningService signingService ) throws Exception { Token token = Token.fromCbor(tokenBytes); - Assertions.assertEquals(VerificationStatus.OK, token.verify(trustBase, predicateVerifier, mintJustificationVerifier).getStatus()); - - byte[] x = new byte[32]; - new SecureRandom().nextBytes(x); + Assertions.assertEquals(VerificationStatus.OK, token.verify(context).getStatus()); TransferTransaction transaction = TransferTransaction.create( token, recipient, - x, + StateMask.generate(), null ); return TokenUtils.transferToken( client, - trustBase, - predicateVerifier, + context, token, transaction, SignaturePredicateUnlockScript.create(transaction, signingService) @@ -182,8 +155,7 @@ public static Token transferToken( * Submit a prepared transfer transaction and return resulting transferred token. * * @param client state transition client - * @param trustBase trust base - * @param predicateVerifier predicate verifier + * @param context verification context * @param token source token * @param transaction transfer transaction * @param unlockScript unlock script for transaction @@ -194,8 +166,7 @@ public static Token transferToken( */ public static Token transferToken( StateTransitionClient client, - RootTrustBase trustBase, - PredicateVerifierService predicateVerifier, + VerificationContext context, Token token, TransferTransaction transaction, UnlockScript unlockScript @@ -210,18 +181,14 @@ public static Token transferToken( } return token.transfer( - trustBase, - predicateVerifier, transaction.toCertifiedTransaction( - trustBase, - predicateVerifier, + context.getTrustBase(), + context.getPredicateVerifier(), InclusionProofUtils.waitInclusionProof( - client, - trustBase, - predicateVerifier, - transaction - ).get() - ) + client, context.getTrustBase(), context.getPredicateVerifier(), + transaction).get() + ), + context ); }