diff --git a/build.gradle.kts b/build.gradle.kts index 1571de8..b30370f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -65,7 +65,7 @@ dependencies { testImplementation("com.google.guava:guava:33.0.0-jre") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") - // ✅ Cucumber for BDD + // Cucumber for BDD testImplementation("io.cucumber:cucumber-java:7.27.2") testImplementation("io.cucumber:cucumber-junit-platform-engine:7.27.2") @@ -166,7 +166,45 @@ tasks.register("advancedTokenTests") { shouldRunAfter(tasks.test) } -// ✅ Run all cucumber tests (including integration) +tasks.register("shardTests") { + useJUnitPlatform() + maxHeapSize = "1024m" + systemProperty("cucumber.junit-platform.naming-strategy", "long") + systemProperties = System.getProperties().toMap() as Map + systemProperty("cucumber.filter.tags", "@shard-routing") + + filter { + includeTestsMatching("*CucumberTestRunner*") + } + shouldRunAfter(tasks.test) +} + +tasks.register("multiAggregatorTests") { + useJUnitPlatform() + maxHeapSize = "1024m" + systemProperty("cucumber.junit-platform.naming-strategy", "long") + systemProperties = System.getProperties().toMap() as Map + systemProperty("cucumber.filter.tags", "@multi-aggregator and not @ignore") + + filter { + includeTestsMatching("*CucumberTestRunner*") + } + shouldRunAfter(tasks.test) +} + +tasks.register("perfTests") { + useJUnitPlatform() + maxHeapSize = "2048m" + systemProperty("cucumber.junit-platform.naming-strategy", "long") + systemProperties = System.getProperties().toMap() as Map + systemProperty("cucumber.filter.tags", "@performance") + + filter { + includeTestsMatching("*CucumberTestRunner*") + } + shouldRunAfter(tasks.test) +} + tasks.register("allCucumberTests") { useJUnitPlatform() maxHeapSize = "1024m" @@ -180,6 +218,84 @@ tasks.register("allCucumberTests") { shouldRunAfter(tasks.test) } +tasks.register("lifecycleTests") { + useJUnitPlatform() + maxHeapSize = "1024m" + systemProperty("cucumber.junit-platform.naming-strategy", "long") + systemProperties = System.getProperties().toMap() as Map + systemProperty("cucumber.filter.tags", "@token-lifecycle") + + filter { + includeTestsMatching("*CucumberTestRunner*") + } + shouldRunAfter(tasks.test) +} + +tasks.register("predicateMatrixTests") { + useJUnitPlatform() + maxHeapSize = "1024m" + systemProperty("cucumber.junit-platform.naming-strategy", "long") + systemProperties = System.getProperties().toMap() as Map + systemProperty("cucumber.filter.tags", "@predicate-matrix") + + filter { + includeTestsMatching("*CucumberTestRunner*") + } + shouldRunAfter(tasks.test) +} + +tasks.register("authorizationTests") { + useJUnitPlatform() + maxHeapSize = "1024m" + systemProperty("cucumber.junit-platform.naming-strategy", "long") + systemProperties = System.getProperties().toMap() as Map + systemProperty("cucumber.filter.tags", "@authorization") + + filter { + includeTestsMatching("*CucumberTestRunner*") + } + shouldRunAfter(tasks.test) +} + +tasks.register("splitBoundaryTests") { + useJUnitPlatform() + maxHeapSize = "1024m" + systemProperty("cucumber.junit-platform.naming-strategy", "long") + systemProperties = System.getProperties().toMap() as Map + systemProperty("cucumber.filter.tags", "@split-boundaries") + + filter { + includeTestsMatching("*CucumberTestRunner*") + } + shouldRunAfter(tasks.test) +} + +tasks.register("multiLevelSplitTests") { + useJUnitPlatform() + maxHeapSize = "1024m" + systemProperty("cucumber.junit-platform.naming-strategy", "long") + systemProperties = System.getProperties().toMap() as Map + systemProperty("cucumber.filter.tags", "@multi-level-split") + + filter { + includeTestsMatching("*CucumberTestRunner*") + } + shouldRunAfter(tasks.test) +} + +tasks.register("edgeCaseTests") { + useJUnitPlatform() + maxHeapSize = "1024m" + systemProperty("cucumber.junit-platform.naming-strategy", "long") + systemProperties = System.getProperties().toMap() as Map + systemProperty("cucumber.filter.tags", "@edge-cases") + + filter { + includeTestsMatching("*CucumberTestRunner*") + } + shouldRunAfter(tasks.test) +} + // Create separate JARs for each platform tasks.register("androidJar") { archiveClassifier.set("android") diff --git a/src/test/java/org/unicitylabs/sdk/e2e/CucumberTestRunner.java b/src/test/java/org/unicitylabs/sdk/e2e/CucumberTestRunner.java index 4029145..b3aa35c 100644 --- a/src/test/java/org/unicitylabs/sdk/e2e/CucumberTestRunner.java +++ b/src/test/java/org/unicitylabs/sdk/e2e/CucumberTestRunner.java @@ -4,22 +4,24 @@ import org.junit.platform.suite.api.*; /** - * Updated Cucumber test runner configuration for E2E tests. - * This class configures the test execution environment and feature discovery - * with the new shared step definitions approach. + * Cucumber test runner configuration for E2E tests. + * Configures test execution environment and feature discovery. + * + * Safety: tasks.test excludes *CucumberTestRunner* — cucumber tests + * only run via dedicated gradle tasks (tokenTests, aggregatorTests, etc.) */ -//@Suite -//@IncludeEngines("cucumber") -//@SelectPackages("org.unicitylabs.sdk.features") -//@ConfigurationParameter(key = Constants.GLUE_PROPERTY_NAME, value = "org.unicitylabs.sdk.e2e.steps,org.unicitylabs.sdk.e2e.steps.shared,org.unicitylabs.sdk.e2e.config") -//@ConfigurationParameter(key = Constants.PLUGIN_PROPERTY_NAME, value = "pretty,html:build/cucumber-reports/cucumber.html,json:build/cucumber-reports/cucumber.json") -//@ConfigurationParameter(key = Constants.EXECUTION_DRY_RUN_PROPERTY_NAME, value = "false") -//@ConfigurationParameter(key = Constants.PLUGIN_PUBLISH_QUIET_PROPERTY_NAME, value = "true") -//public class CucumberTestRunner { -// static { -// // Only set default tags if no tags are specified -// if (System.getProperty("cucumber.filter.tags") == null) { -// System.setProperty("cucumber.filter.tags", "not @ignore"); -// } -// } -//} \ No newline at end of file +@Suite +@IncludeEngines("cucumber") +@SelectPackages("org.unicitylabs.sdk.features") +@ConfigurationParameter(key = Constants.GLUE_PROPERTY_NAME, value = "org.unicitylabs.sdk.e2e.steps,org.unicitylabs.sdk.e2e.steps.shared,org.unicitylabs.sdk.e2e.config") +@ConfigurationParameter(key = Constants.PLUGIN_PROPERTY_NAME, value = "pretty,html:build/cucumber-reports/cucumber.html,json:build/cucumber-reports/cucumber.json") +@ConfigurationParameter(key = Constants.EXECUTION_DRY_RUN_PROPERTY_NAME, value = "false") +@ConfigurationParameter(key = Constants.PLUGIN_PUBLISH_QUIET_PROPERTY_NAME, value = "true") +public class CucumberTestRunner { + static { + // Only set default tags if no tags are specified + if (System.getProperty("cucumber.filter.tags") == null) { + System.setProperty("cucumber.filter.tags", "not @ignore"); + } + } +} diff --git a/src/test/java/org/unicitylabs/sdk/e2e/config/AggregatorConfig.java b/src/test/java/org/unicitylabs/sdk/e2e/config/AggregatorConfig.java new file mode 100644 index 0000000..2a6d1b2 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/e2e/config/AggregatorConfig.java @@ -0,0 +1,104 @@ +package org.unicitylabs.sdk.e2e.config; + +import java.util.HashMap; +import java.util.Map; + +/** + * Centralized aggregator URL and shard topology configuration. + * + * Standalone: sdk.aggregator.url or AGGREGATOR_URL (default http://localhost:3000). + * Sharded: sdk.shard.id.length + sdk.shard..url for each shard. + * Explicit shard-to-URL mapping — no ordering concerns. + */ +public final class AggregatorConfig { + + private AggregatorConfig() { + } + + /** + * Returns the single aggregator URL. + * Resolution order: system property sdk.aggregator.url, env AGGREGATOR_URL, + * default http://localhost:3000. + */ + public static String getSingleUrl() { + String url = System.getProperty("sdk.aggregator.url"); + if (url != null && !url.isEmpty()) { + return url; + } + url = System.getenv("AGGREGATOR_URL"); + if (url != null && !url.isEmpty()) { + return url; + } + return "http://192.168.43.106:3000"; + } + + /** + * Returns the shard ID length. + * Resolution order: system property sdk.shard.id.length, env SHARD_ID_LENGTH, + * default 1. + */ + public static int getShardIdLength() { + String val = System.getProperty("sdk.shard.id.length"); + if (val != null && !val.isEmpty()) { + return Integer.parseInt(val); + } + val = System.getenv("SHARD_ID_LENGTH"); + if (val != null && !val.isEmpty()) { + return Integer.parseInt(val); + } + return 1; + } + + /** + * Returns an explicit {shardId -> url} mapping for all expected shards. + * + * Shard IDs use a leading 1-bit prefix: + * shardIdLength=1 -> shardIds: 2, 3 (binary: 10, 11) + * shardIdLength=2 -> shardIds: 4, 5, 6, 7 (binary: 100, 101, 110, 111) + * + * Per-shard URL resolution: system property sdk.shard..url, then env SHARD__URL. + * + * @throws IllegalStateException if any expected shard is missing a URL + */ + public static Map getShardUrlMap() { + int shardIdLength = getShardIdLength(); + int baseId = 1 << shardIdLength; + int shardCount = 1 << shardIdLength; + + Map map = new HashMap<>(); + java.util.List missing = new java.util.ArrayList<>(); + + for (int i = 0; i < shardCount; i++) { + int shardId = baseId + i; + String url = resolveShardUrl(shardId); + if (url == null || url.isEmpty()) { + missing.add(shardId); + } else { + map.put(shardId, url); + } + } + + if (!missing.isEmpty()) { + throw new IllegalStateException( + "Missing shard URL configuration for shard IDs: " + missing + + ". Configure via -Dsdk.shard..url= or env SHARD__URL" + ); + } + + return map; + } + + private static String resolveShardUrl(int shardId) { + String propKey = "sdk.shard." + shardId + ".url"; + String url = System.getProperty(propKey); + if (url != null && !url.isEmpty()) { + return url; + } + String envKey = "SHARD_" + shardId + "_URL"; + url = System.getenv(envKey); + if (url != null && !url.isEmpty()) { + return url; + } + return null; + } +} diff --git a/src/test/java/org/unicitylabs/sdk/e2e/context/TestContext.java b/src/test/java/org/unicitylabs/sdk/e2e/context/TestContext.java index e5500f2..95433fe 100644 --- a/src/test/java/org/unicitylabs/sdk/e2e/context/TestContext.java +++ b/src/test/java/org/unicitylabs/sdk/e2e/context/TestContext.java @@ -13,6 +13,7 @@ import org.unicitylabs.sdk.transaction.Transaction; import org.unicitylabs.sdk.transaction.TransferTransaction; import org.unicitylabs.sdk.utils.TestUtils; +import org.unicitylabs.sdk.utils.helpers.AggregatorRequestHelper; import org.unicitylabs.sdk.utils.helpers.CommitmentResult; import org.unicitylabs.sdk.utils.helpers.PendingTransfer; @@ -34,7 +35,11 @@ public class TestContext { // User management - private Map userSigningServices = new HashMap<>(); + // Preserves the token as it was immediately after minting. Required for + // double-spend testing: after a transfer the token object is consumed, + // so we need the original to attempt a second spend. + private Map originalMintedTokens = new HashMap<>(); + private Map userSigningServices = new LinkedHashMap<>(); private Map userNonces = new HashMap<>(); private Map userSecrets = new HashMap<>(); private Map userPredicate = new HashMap<>(); @@ -72,13 +77,35 @@ public class TestContext { // Current operation context private String currentUser; + private String lastSplitRecipient; + private List lastSplitTokens = new ArrayList<>(); private String expectedErrorType; private int expectedSplitCount; private int configuredUserCount; private int configuredTokensPerUser; + private List aggregatorUrls; + private AggregatorRequestHelper shardHelper; + + public AggregatorRequestHelper getShardHelper() { + return shardHelper; + } + + public void setShardHelper(AggregatorRequestHelper shardHelper) { + this.shardHelper = shardHelper; + } + + public List getAggregatorUrls() { + return aggregatorUrls; + } + + public void setAggregatorUrls(List aggregatorUrls) { + this.aggregatorUrls = aggregatorUrls; + } // Getters and Setters + public Map getOriginalMintedTokens() { return originalMintedTokens; } + public AggregatorClient getAggregatorClient() { return aggregatorClient; } public void setAggregatorClient(AggregatorClient aggregatorClient) { this.aggregatorClient = aggregatorClient; } @@ -119,6 +146,53 @@ public void setAggregatorClients(List aggregatorClients) { public Map> getUserTokens() { return userTokens; } public void setUserTokens(Map> userTokens) { this.userTokens = userTokens; } + + /** + * Removes a token owned by a user using any of: + * - the token object itself + * - its index in the user's token list + * - its token ID + * + * @param userName name of the user + * @param identifier can be Token, Integer (index), or TokenId / String (id) + * @return true if a token was removed, false otherwise + */ + public boolean removeUserToken(String userName, Object identifier) { + List tokens = userTokens.get(userName); + if (tokens == null || tokens.isEmpty()) { + return false; + } + + // 1️⃣ Case: remove by token object + if (identifier instanceof Token) { + return tokens.remove((Token) identifier); + } + + // 2️⃣ Case: remove by index (Integer) + if (identifier instanceof Integer) { + int index = (Integer) identifier; + if (index >= 0 && index < tokens.size()) { + tokens.remove(index); + return true; + } + return false; + } + + // 3️⃣ Case: remove by token ID (TokenId or String) + if (identifier instanceof TokenId) { + TokenId tokenId = (TokenId) identifier; + return tokens.removeIf(t -> t.getId().equals(tokenId)); + } + + if (identifier instanceof String) { + String tokenIdString = (String) identifier; + return tokens.removeIf(t -> t.getId().toString().equals(tokenIdString)); + } + + // 4️⃣ Unknown identifier type + return false; + } + public Map> getNameTagTokens() { return nameTagTokens; } public void setNameTagTokens(Map> nameTagTokens) { this.nameTagTokens = nameTagTokens; } @@ -179,6 +253,12 @@ public void setAggregatorClients(List aggregatorClients) { public String getCurrentUser() { return currentUser; } public void setCurrentUser(String currentUser) { this.currentUser = currentUser; } + public String getLastSplitRecipient() { return lastSplitRecipient; } + public void setLastSplitRecipient(String lastSplitRecipient) { this.lastSplitRecipient = lastSplitRecipient; } + + public List getLastSplitTokens() { return lastSplitTokens; } + public void setLastSplitTokens(List lastSplitTokens) { this.lastSplitTokens = lastSplitTokens; } + public String getExpectedErrorType() { return expectedErrorType; } public void setExpectedErrorType(String expectedErrorType) { this.expectedErrorType = expectedErrorType; } @@ -212,7 +292,7 @@ public void addUserToken(String userName, Token token) { public Token getUserToken(String userName) { List tokens = userTokens.get(userName); - return (tokens != null && !tokens.isEmpty()) ? tokens.get(0) : null; + return (tokens != null && !tokens.isEmpty()) ? tokens.get(tokens.size() - 1) : null; } public Token getUserToken(String userName, int index) { @@ -263,6 +343,8 @@ public void clearUserData() { } public void clearTestState() { + originalMintedTokens.clear(); + aggregatorUrls = null; configuredUserCount = 0; blockHeight = null; randomSecret = null; @@ -279,7 +361,12 @@ public void clearTestState() { chainToken = null; transferCustomData.clear(); currentUser = null; + lastSplitRecipient = null; + lastSplitTokens.clear(); expectedErrorType = null; + nameTagTokens.clear(); + pendingTransfers.clear(); + userNametagRelations.clear(); } public void reset() { diff --git a/src/test/java/org/unicitylabs/sdk/e2e/steps/AdvancedStepDefinitions.java b/src/test/java/org/unicitylabs/sdk/e2e/steps/AdvancedStepDefinitions.java index 2eec822..71dd3ba 100644 --- a/src/test/java/org/unicitylabs/sdk/e2e/steps/AdvancedStepDefinitions.java +++ b/src/test/java/org/unicitylabs/sdk/e2e/steps/AdvancedStepDefinitions.java @@ -106,11 +106,14 @@ public void theFinalTokenShouldMaintainOriginalProperties() { // Additional property validation can be added based on requirements } - @And("all intermediate transfers should be recorded correctly") - public void allIntermediateTransfersShouldBeRecordedCorrectly() { - assertEquals(4, context.getTransferChain().size(), "Transfer chain should have 4 users"); - assertEquals("Alice", context.getTransferChain().get(0), "Chain should start with Alice"); - assertEquals("Bob", context.getTransferChain().get(3), "Chain should end with Dave"); + @And("the transfer chain should have {int} participants from {string} to {string}") + public void theTransferChainShouldHaveParticipants(int expectedSize, String startUser, String endUser) { + assertEquals(expectedSize, context.getTransferChain().size(), + "Transfer chain should have " + expectedSize + " participants"); + assertEquals(startUser, context.getTransferChain().get(0), + "Chain should start with " + startUser); + assertEquals(endUser, context.getTransferChain().get(expectedSize - 1), + "Chain should end with " + endUser); } @And("the token should have transfers in history") diff --git a/src/test/java/org/unicitylabs/sdk/e2e/steps/NewScenarioStepDefinitions.java b/src/test/java/org/unicitylabs/sdk/e2e/steps/NewScenarioStepDefinitions.java new file mode 100644 index 0000000..551e8ad --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/e2e/steps/NewScenarioStepDefinitions.java @@ -0,0 +1,222 @@ +package org.unicitylabs.sdk.e2e.steps; + +import static org.junit.jupiter.api.Assertions.*; + +import io.cucumber.java.en.And; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.util.List; +import org.unicitylabs.sdk.address.ProxyAddress; +import org.unicitylabs.sdk.e2e.config.CucumberConfiguration; +import org.unicitylabs.sdk.e2e.context.TestContext; +import org.unicitylabs.sdk.e2e.steps.shared.StepHelper; +import org.unicitylabs.sdk.token.Token; +import org.unicitylabs.sdk.verification.VerificationResult; + +/** + * Step definitions for new BDD scenarios: + * - Token lifecycle (burned token, pre-transfer reuse) + * - Authorization (non-owner transfer prevention) + * - CBOR serialization round-trip + * - Split boundary conditions + * - Edge cases + */ +public class NewScenarioStepDefinitions { + + private final TestContext context; + private final StepHelper helper = new StepHelper(); + + // Saved state for specific scenarios + private Token savedTokenReference; + private Token importedToken; + private Token originalTokenBeforeExport; + + public NewScenarioStepDefinitions() { + this.context = CucumberConfiguration.getTestContext(); + } + + // ========== Token Lifecycle Steps ========== + + @Then("{string} should not be able to transfer the burned token to {string}") + public void shouldNotBeAbleToTransferBurnedToken(String fromUser, String toUser) { + Token originalToken = context.getOriginalMintedTokens().get(fromUser); + assertNotNull(originalToken, "No original minted token saved for " + fromUser); + try { + ProxyAddress proxyAddress = ProxyAddress.create(context.getNameTagToken(toUser).getId()); + helper.transferToken(fromUser, toUser, originalToken, proxyAddress, null); + fail("Transfer of burned token should have been rejected"); + } catch (Exception e) { + // Expected: burned token cannot be transferred + } + } + + @And("{string} saves a reference to the current token") + public void savesAReferenceToTheCurrentToken(String username) { + savedTokenReference = context.getUserToken(username); + assertNotNull(savedTokenReference, "Token reference should exist for " + username); + } + + @When("{string} attempts to reuse the saved token reference to transfer to {string}") + public void attemptsToReuseTheSavedTokenReference(String fromUser, String toUser) { + assertNotNull(savedTokenReference, "No saved token reference"); + try { + ProxyAddress proxyAddress = ProxyAddress.create(context.getNameTagToken(toUser).getId()); + helper.transferToken(fromUser, toUser, savedTokenReference, proxyAddress, null); + context.setOperationSucceeded(true); + context.setLastError(null); + } catch (Exception e) { + context.setOperationSucceeded(false); + context.setLastError(e); + } + } + + @Then("the reuse attempt should be rejected") + public void theReuseAttemptShouldBeRejected() { + assertFalse(context.isOperationSucceeded(), + "Reuse of pre-transfer token reference should have been rejected"); + } + + // ========== Authorization Steps ========== + + @When("{string} attempts to transfer {string}'s token to {string} using a proxy address") + public void attemptsToTransferOthersTokenUsingProxy(String attacker, String owner, String recipient) { + Token token = context.getUserToken(owner); + assertNotNull(token, "No token found for " + owner); + try { + ProxyAddress proxyAddress = ProxyAddress.create(context.getNameTagToken(recipient).getId()); + helper.transferToken(attacker, recipient, token, proxyAddress, null); + context.setOperationSucceeded(true); + context.setLastError(null); + } catch (Exception e) { + context.setOperationSucceeded(false); + context.setLastError(e); + } + } + + @Then("the unauthorized transfer should fail") + public void theUnauthorizedTransferShouldFail() { + assertFalse(context.isOperationSucceeded(), + "Unauthorized transfer should have failed but succeeded. Error: " + + (context.getLastError() != null ? context.getLastError().getMessage() : "none")); + } + + // ========== CBOR Serialization Steps ========== + + @When("the token for {string} is exported to CBOR and imported back") + public void theTokenIsExportedToCborAndImportedBack(String username) { + Token token = context.getUserToken(username); + assertNotNull(token, "No token found for " + username); + originalTokenBeforeExport = token; + + byte[] cborBytes = token.toCbor(); + assertNotNull(cborBytes, "CBOR export should produce bytes"); + assertTrue(cborBytes.length > 0, "CBOR export should produce non-empty bytes"); + + importedToken = Token.fromCbor(cborBytes); + assertNotNull(importedToken, "CBOR import should produce a token"); + } + + @Then("the imported token should have the same ID as the original") + public void theImportedTokenShouldHaveTheSameId() { + assertNotNull(importedToken, "Imported token should exist"); + assertNotNull(originalTokenBeforeExport, "Original token should exist"); + assertEquals(originalTokenBeforeExport.getId(), importedToken.getId(), + "Imported token ID should match original"); + } + + @Then("the imported token should have the same type as the original") + public void theImportedTokenShouldHaveTheSameType() { + assertNotNull(importedToken, "Imported token should exist"); + assertNotNull(originalTokenBeforeExport, "Original token should exist"); + assertEquals(originalTokenBeforeExport.getType(), importedToken.getType(), + "Imported token type should match original"); + } + + @Then("the imported token should pass verification") + public void theImportedTokenShouldPassVerification() { + assertNotNull(importedToken, "Imported token should exist"); + VerificationResult result = importedToken.verify(context.getTrustBase()); + assertTrue(result.isSuccessful(), + "Imported token should pass verification but failed: " + result); + } + + @Then("the imported token should have {int} transactions in its history") + public void theImportedTokenShouldHaveTransactionsInHistory(int expectedCount) { + assertNotNull(importedToken, "Imported token should exist"); + assertEquals(expectedCount, importedToken.getTransactions().size(), + "Imported token should have expected number of transactions"); + } + + // ========== Token Property Verification (per-user) ========== + + @And("the token for {string} should have {int} transactions in its history") + public void theTokenForUserShouldHaveTransactions(String username, int expectedCount) { + Token token = context.getUserToken(username); + assertNotNull(token, "Token should exist for " + username); + assertEquals(expectedCount, token.getTransactions().size(), + "Token for " + username + " should have " + expectedCount + " transactions"); + } + + @And("the token for {string} should maintain its original ID and type from {string}") + public void theTokenShouldMaintainOriginalIdAndType(String currentOwner, String originalOwner) { + Token currentToken = context.getUserToken(currentOwner); + Token originalToken = context.getOriginalMintedTokens().get(originalOwner); + assertNotNull(currentToken, "Current token should exist for " + currentOwner); + assertNotNull(originalToken, "Original minted token should exist for " + originalOwner); + assertEquals(originalToken.getId(), currentToken.getId(), + "Token ID should be preserved through transfers"); + assertEquals(originalToken.getType(), currentToken.getType(), + "Token type should be preserved through transfers"); + } + + // ========== Split Boundary Steps ========== + + @Then("both split tokens should be valid") + public void bothSplitTokensShouldBeValid() { + List tokens = context.getLastSplitTokens(); + assertNotNull(tokens, "Split tokens should exist"); + assertTrue(tokens.size() >= 2, "Should have at least 2 split tokens"); + + for (Token token : tokens) { + VerificationResult result = token.verify(context.getTrustBase()); + assertTrue(result.isSuccessful(), + "Split token should be valid but failed: " + result); + } + } + + @Then("the split token values should sum to the original value") + public void theSplitTokenValuesShouldSumToOriginalValue() { + String currentUser = context.getCurrentUser(); + Token originalToken = context.getOriginalMintedTokens().get(currentUser); + List splitTokens = context.getLastSplitTokens(); + assertNotNull(originalToken, "Original token should exist"); + assertNotNull(splitTokens, "Split tokens should exist"); + + // Verify both split tokens have coin data + for (Token token : splitTokens) { + assertTrue(token.getCoins().isPresent(), + "Split token should have coin data"); + } + } + + // ========== Multi-Level Split Steps ========== + + @When("{string} attempts to double-spend the split token to {string}") + public void attemptsToDoubleSpendSplitToken(String fromUser, String toUser) { + // The first split token was already transferred; try to transfer it again + List tokens = context.getUserTokens().get(fromUser); + assertNotNull(tokens, "User should have tokens"); + assertFalse(tokens.isEmpty(), "User should have at least one token"); + + Token tokenToDoubleSpend = tokens.get(0); + try { + ProxyAddress proxyAddress = ProxyAddress.create(context.getNameTagToken(toUser).getId()); + helper.transferToken(fromUser, toUser, tokenToDoubleSpend, proxyAddress, null); + context.setOperationSucceeded(true); + context.setLastError(null); + } catch (Exception e) { + context.setOperationSucceeded(false); + context.setLastError(e); + } + } +} diff --git a/src/test/java/org/unicitylabs/sdk/e2e/steps/StepDefinitions.java b/src/test/java/org/unicitylabs/sdk/e2e/steps/StepDefinitions.java index e799d47..1879e47 100644 --- a/src/test/java/org/unicitylabs/sdk/e2e/steps/StepDefinitions.java +++ b/src/test/java/org/unicitylabs/sdk/e2e/steps/StepDefinitions.java @@ -1,5 +1,6 @@ package org.unicitylabs.sdk.e2e.steps; +import io.cucumber.datatable.DataTable; import org.unicitylabs.sdk.address.ProxyAddress; import org.unicitylabs.sdk.e2e.config.CucumberConfiguration; import org.unicitylabs.sdk.e2e.context.TestContext; @@ -105,6 +106,53 @@ public void theUserMintsATokenOfTypeWithCoinDataContainingCoins(String tokenType context.addUserToken(user, token); } + @When("the {string} mints a token of type {string} with coins data desctribed below") + public void theUserMintsATokenOfTypeWithCoins(String username,String tokenType, DataTable dataTable) throws Exception { + context.setCurrentUser(username); + String user = context.getCurrentUser(); + TokenId tokenId = TestUtils.generateRandomTokenId(); + TokenType type = TestUtils.createTokenTypeFromString(tokenType); + List> coinRows = dataTable.asMaps(String.class, String.class); + + for (Map row : coinRows) { + String name = row.get("name"); + String symbol = row.get("symbol"); + String id = row.get("id"); + int decimals = Integer.parseInt(row.get("decimals")); + int value = Integer.parseInt(row.get("value")); + + System.out.printf( + "Token type: %s | Name: %s | Symbol: %s | ID: %s | Decimals: %d | Value: %d%n", + tokenType, name, symbol, id, decimals, value + ); + + // 🪙 Your actual mint logic goes here + // e.g. TestUtils.mintToken(tokenType, name, symbol, id, decimals, value); + } + + // Convert table → TokenCoinData + TokenCoinData coinData = TestUtils.createCoinDataFromTable(coinRows); + + Token token = TestUtils.mintTokenForUser( + context.getClient(), + context.getUserSigningServices().get(user), + context.getUserNonces().get(user), + tokenId, + type, + coinData, + context.getTrustBase() + ); + // do post-processing here (still in parallel) + if (TestUtils.validateTokenOwnership( + token, + context.getUserSigningServices().get(user), + context.getTrustBase() + )) { + context.addUserToken(user, token); + } + context.setCurrentUser(user); + } + @Then("the token should be minted successfully") public void theTokenShouldBeMintedSuccessfully() { String user = context.getCurrentUser(); diff --git a/src/test/java/org/unicitylabs/sdk/e2e/steps/shared/PredicateType.java b/src/test/java/org/unicitylabs/sdk/e2e/steps/shared/PredicateType.java new file mode 100644 index 0000000..2a44402 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/e2e/steps/shared/PredicateType.java @@ -0,0 +1,5 @@ +package org.unicitylabs.sdk.e2e.steps.shared; + +public enum PredicateType { + MASKED, UNMASKED, NAMETAG_AWARE +} \ No newline at end of file diff --git a/src/test/java/org/unicitylabs/sdk/e2e/steps/shared/SharedStepDefinitions.java b/src/test/java/org/unicitylabs/sdk/e2e/steps/shared/SharedStepDefinitions.java index 3e336d6..6851f3d 100644 --- a/src/test/java/org/unicitylabs/sdk/e2e/steps/shared/SharedStepDefinitions.java +++ b/src/test/java/org/unicitylabs/sdk/e2e/steps/shared/SharedStepDefinitions.java @@ -1,17 +1,28 @@ package org.unicitylabs.sdk.e2e.steps.shared; import org.unicitylabs.sdk.StateTransitionClient; +import org.unicitylabs.sdk.address.AddressFactory; import org.unicitylabs.sdk.address.DirectAddress; import org.unicitylabs.sdk.address.ProxyAddress; import org.unicitylabs.sdk.api.*; import org.unicitylabs.sdk.bft.RootTrustBase; +import org.unicitylabs.sdk.e2e.config.AggregatorConfig; import org.unicitylabs.sdk.e2e.config.CucumberConfiguration; import org.unicitylabs.sdk.e2e.context.TestContext; +import org.unicitylabs.sdk.predicate.Predicate; import org.unicitylabs.sdk.predicate.PredicateEngineService; +import org.unicitylabs.sdk.predicate.embedded.MaskedPredicate; import org.unicitylabs.sdk.predicate.embedded.UnmaskedPredicate; +import org.unicitylabs.sdk.predicate.embedded.UnmaskedPredicateReference; import org.unicitylabs.sdk.serializer.UnicityObjectMapper; +import org.unicitylabs.sdk.token.TokenState; +import org.unicitylabs.sdk.token.fungible.CoinId; import org.unicitylabs.sdk.transaction.*; +import org.unicitylabs.sdk.transaction.split.SplitMintReason; +import org.unicitylabs.sdk.transaction.split.TokenSplitBuilder; +import org.unicitylabs.sdk.util.InclusionProofUtils; import org.unicitylabs.sdk.utils.TestUtils; +import org.unicitylabs.sdk.utils.TokenUtils; import org.unicitylabs.sdk.hash.DataHash; import org.unicitylabs.sdk.hash.HashAlgorithm; import org.unicitylabs.sdk.signing.SigningService; @@ -26,18 +37,24 @@ import io.cucumber.java.en.When; import java.io.IOException; +import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.concurrent.*; import java.util.stream.Collectors; -import static org.unicitylabs.sdk.utils.TestUtils.randomCoinData; +import static org.unicitylabs.sdk.util.InclusionProofUtils.waitInclusionProof; +import static org.unicitylabs.sdk.utils.TestUtils.randomBytes; import static org.junit.jupiter.api.Assertions.*; +import org.unicitylabs.sdk.utils.LoggingAggregatorClient; +import org.unicitylabs.sdk.utils.helpers.AggregatorRequestHelper; import org.unicitylabs.sdk.utils.helpers.CommitmentResult; import org.unicitylabs.sdk.verification.VerificationResult; + + /** * Shared step definitions that can be reused across multiple feature files. * These steps use TestContext to maintain state and avoid duplication. @@ -55,12 +72,9 @@ public SharedStepDefinitions() { // ✅ Public zero-argument constructor // Setup Steps @Given("the aggregator URL is configured") public void theAggregatorUrlIsConfigured() { -// String aggregatorUrl = System.getenv("AGGREGATOR_URL"); - String aggregatorUrl = "http://localhost:3000"; - - - assertNotNull(aggregatorUrl, "AGGREGATOR_URL environment variable must be set"); - context.setAggregatorClient(new JsonRpcAggregatorClient(aggregatorUrl)); + String aggregatorUrl = AggregatorConfig.getSingleUrl(); + assertNotNull(aggregatorUrl, "Aggregator URL must be configured"); + context.setAggregatorClient(new LoggingAggregatorClient(new JsonRpcAggregatorClient(aggregatorUrl))); } @And("the aggregator client is initialized") @@ -231,6 +245,7 @@ public void userMintsATokenWithRandomCoinData(String username) throws Exception TokenType tokenType = TestUtils.generateRandomTokenType();; TokenCoinData coinData = TestUtils.createRandomCoinData(2); + // Mint with masked predicate n0 (internally) Token token = TestUtils.mintTokenForUser( context.getClient(), context.getUserSigningServices().get(username), @@ -241,13 +256,13 @@ public void userMintsATokenWithRandomCoinData(String username) throws Exception context.getTrustBase() ); - // do post-processing here (still in parallel) if (TestUtils.validateTokenOwnership( token, context.getUserSigningServices().get(username), context.getTrustBase() )) { context.addUserToken(username, token); + context.getOriginalMintedTokens().put(username, token); } context.setCurrentUser(username); } @@ -262,18 +277,16 @@ public void userTransfersTheTokenToUserUsingAProxyAddress(String fromUser, Strin @When("{string} transfers the token to {string} using an unmasked predicate") public void userTransfersTheTokenToUserUsingAnUnmaskedPredicate(String fromUser, String toUser) throws Exception { Token sourceToken = context.getUserToken(fromUser); - SigningService toSigningService = context.getUserSigningServices().get(toUser); + SigningService carolSigningService = SigningService.createFromSecret(context.getUserSecret().get(toUser)); - UnmaskedPredicate userPredicate = UnmaskedPredicate.create( - sourceToken.getId(), + UnmaskedPredicateReference reference = UnmaskedPredicateReference.create( sourceToken.getType(), - toSigningService, - HashAlgorithm.SHA256, - context.getUserNonces().get(toUser) + carolSigningService.getAlgorithm(), + carolSigningService.getPublicKey(), + HashAlgorithm.SHA256 ); - context.getUserPredicate().put(toUser, userPredicate); - DirectAddress toAddress = userPredicate.getReference().toAddress(); + DirectAddress toAddress = reference.toAddress(); helper.transferToken(fromUser, toUser, sourceToken, toAddress, null); } @@ -284,9 +297,61 @@ public void userShouldOwnTheTokenSuccessfully(String username) { context.setCurrentUser(username); SigningService signingService = context.getUserSigningServices().get(username); VerificationResult result = token.verify(context.getTrustBase()); - assertTrue(result.isSuccessful(), "Token should be valid"); -// assertTrue(token.getState().getPredicate().getEngine().equals(signingService.getPublicKey()), username + " should own the token"); - assertTrue(PredicateEngineService.createPredicate(token.getState().getPredicate()).isOwner(signingService.getPublicKey()), username + " should own the token"); + assertTrue(result.isSuccessful(), () -> "Token should be valid but failed with reason: " + result); + + assertTrue(PredicateEngineService.createPredicate(token.getState().getPredicate()) + .isOwner(helper.getSigningServiceForToken(username, token).getPublicKey()), username + " should own the token"); + } + + @When("{string} attempts to double-spend the original token to {string} using a proxy address") + public void attemptsToDoubleSpendUsingProxyAddress(String fromUser, String toUser) { + Token originalToken = context.getOriginalMintedTokens().get(fromUser); + assertNotNull(originalToken, "No original minted token saved for " + fromUser); + try { + ProxyAddress proxyAddress = ProxyAddress.create(context.getNameTagToken(toUser).getId()); + helper.transferToken(fromUser, toUser, originalToken, proxyAddress, null); + context.setOperationSucceeded(true); + context.setLastError(null); + } catch (Exception e) { + context.setOperationSucceeded(false); + context.setLastError(e); + } + } + + @When("{string} attempts to double-spend the original token to {string} using an unmasked predicate") + public void attemptsToDoubleSpendUsingUnmaskedPredicate(String fromUser, String toUser) { + Token originalToken = context.getOriginalMintedTokens().get(fromUser); + assertNotNull(originalToken, "No original minted token saved for " + fromUser); + try { + SigningService toSigningService = SigningService.createFromSecret(context.getUserSecret().get(toUser)); + UnmaskedPredicateReference reference = UnmaskedPredicateReference.create( + originalToken.getType(), + toSigningService.getAlgorithm(), + toSigningService.getPublicKey(), + HashAlgorithm.SHA256 + ); + DirectAddress toAddress = reference.toAddress(); + helper.transferToken(fromUser, toUser, originalToken, toAddress, null); + context.setOperationSucceeded(true); + context.setLastError(null); + } catch (Exception e) { + context.setOperationSucceeded(false); + context.setLastError(e); + } + } + + @Then("the double-spend attempt should be rejected") + public void theDoubleSpendAttemptShouldBeRejected() { + assertFalse(context.isOperationSucceeded(), + "Double-spend should have been rejected but succeeded. Error: " + + (context.getLastError() != null ? context.getLastError().getMessage() : "none")); + } + + @Then("{string} should not own any tokens") + public void userShouldNotOwnAnyTokens(String username) { + List tokens = context.getUserTokens().get(username); + assertTrue(tokens == null || tokens.isEmpty(), + username + " should not own any tokens but has " + (tokens != null ? tokens.size() : 0)); } @Then("all mint commitments should receive inclusion proofs within {int} seconds") @@ -298,14 +363,6 @@ public void allMintCommitmentsShouldReceiveInclusionProofs(int timeoutSeconds) t .filter(CommitmentResult::isVerified) .count(); - System.out.println("Verified commitments: " + verifiedCount + " / " + results.size()); - // Print failed ones (not verified) - results.stream() - .filter(r -> !r.isVerified()) - .forEach(r -> System.out.println( - "❌ Commitment failed: requestId=" + r.getRequestId().toString() + ", status=" + r.getStatus() - )); - assertEquals(results.size(), verifiedCount, "All commitments should be verified"); } @@ -326,18 +383,17 @@ public void userCreateANametagTokenWithCustomData(String username, String custom } @Given("the aggregator URLs are configured") - public void theAggregatorURLsAreConfigured() { - // You can either use environment variables or hardcode the URLs - List aggregatorUrls = Arrays.asList( - System.getenv("AGGREGATOR_URL") - ); + public void theAggregatorURLsAreConfigured(DataTable dataTable) { + List aggregatorUrls = dataTable.asList(); + + assertNotNull(aggregatorUrls, "Aggregator URLs must be configured"); assertFalse(aggregatorUrls.isEmpty(), "At least one aggregator URL must be provided"); List clients = new ArrayList<>(); for (String url : aggregatorUrls) { - clients.add(new JsonRpcAggregatorClient(url.trim())); + clients.add(new LoggingAggregatorClient(new JsonRpcAggregatorClient(url.trim(), "premium-key-abc"))); } context.setAggregatorClients(clients); @@ -350,6 +406,230 @@ public void theAggregatorClientsAreInitialized() { assertFalse(clients.isEmpty(), "At least one aggregator client should be initialized"); } + @When("I submit conflicting mint commitments concurrently to all aggregators") + public void iSubmitConflictingMintCommitmentsConcurrentlyToAllAggregators() { + int threadsCount = context.getConfiguredThreadCount(); + int commitmentsPerThread = context.getConfiguredCommitmentsPerThread(); + List aggregatorClients = context.getAggregatorClients(); + Map userSigningServices = context.getUserSigningServices(); + + // Thread pool = threads × aggregators + int totalThreadPoolSize = threadsCount * aggregatorClients.size(); + ExecutorService executor = Executors.newFixedThreadPool(totalThreadPoolSize); + List> futures = new ArrayList<>(); + + // ------------------------------ + // STEP 1: Generate canonical commitments for the FIRST user + // ------------------------------ + Iterator> iterator = userSigningServices.entrySet().iterator(); + if (!iterator.hasNext()) { + throw new IllegalStateException("No users configured in context"); + } + + Map.Entry firstUserEntry = iterator.next(); + SigningService firstUserService = firstUserEntry.getValue(); + + List sharedRequestIds = new ArrayList<>(); + List canonicalTxDataHashes = new ArrayList<>(); + List canonicalStateHashes = new ArrayList<>(); + + for (int i = 0; i < commitmentsPerThread; i++) { + byte[] stateBytes = TestUtils.generateRandomBytes(32); + byte[] txData = TestUtils.generateRandomBytes(32); + DataHash stateHash = TestUtils.hashData(stateBytes); + DataHash txDataHash = TestUtils.hashData(txData); + RequestId requestId = TestUtils.createRequestId(firstUserService, stateHash); + + sharedRequestIds.add(requestId); + canonicalTxDataHashes.add(txDataHash); + canonicalStateHashes.add(stateHash); + } + + // ------------------------------ + // STEP 2: Submit commitments for ALL users + // ------------------------------ + for (Map.Entry entry : userSigningServices.entrySet()) { + String userName = entry.getKey(); + SigningService signingService = entry.getValue(); + + for (int i = 0; i < commitmentsPerThread; i++) { + // Get canonical requestId (same as user 1) + RequestId reusedRequestId = sharedRequestIds.get(i); + + // Create DIFFERENT commitment data (simulate conflicting payloads) + byte[] newStateBytes = TestUtils.generateRandomBytes(32); + byte[] newTxData = TestUtils.generateRandomBytes(32); + DataHash newStateHash = TestUtils.hashData(newStateBytes); + DataHash newTxDataHash = TestUtils.hashData(newTxData); + + for (int aggIndex = 0; aggIndex < aggregatorClients.size(); aggIndex++) { + AggregatorClient aggregatorClient = aggregatorClients.get(aggIndex); + String aggregatorId = "Aggregator" + aggIndex; + + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + long start = System.nanoTime(); + try { + Authenticator authenticator = TestUtils.createAuthenticator( + signingService, newTxDataHash, newStateHash); + + SubmitCommitmentResponse response = aggregatorClient + .submitCommitment(reusedRequestId, newTxDataHash, authenticator) + .get(); + + boolean success = response.getStatus() == SubmitCommitmentStatus.SUCCESS; + long end = System.nanoTime(); + + return new CommitmentResult( + userName + "-" + aggregatorId, + Thread.currentThread().getName(), + reusedRequestId, + success, + start, + end); + } catch (Exception e) { + long end = System.nanoTime(); + return new CommitmentResult( + userName + "-" + aggregatorId, + Thread.currentThread().getName(), + reusedRequestId, + false, + start, + end); + } + }, executor); + + futures.add(future); + } + } + } + + context.setCommitmentFutures(futures); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + executor.shutdown(); + } + + @When("I submit mixed valid and conflicting commitments concurrently to all aggregators") + public void iSubmitMixedValidAndConflictingCommitmentsConcurrentlyToAllAggregators() { + int threadsCount = context.getConfiguredThreadCount(); + int commitmentsPerThread = context.getConfiguredCommitmentsPerThread(); + List aggregatorClients = context.getAggregatorClients(); + Map userSigningServices = context.getUserSigningServices(); + + // Create a thread pool sized for users × aggregators + int totalThreadPoolSize = threadsCount * aggregatorClients.size(); + ExecutorService executor = Executors.newFixedThreadPool(totalThreadPoolSize); + List> futures = new ArrayList<>(); + + // ----------------------------- + // STEP 1: Prepare canonical commitments for FIRST USER + // ----------------------------- + Iterator> iterator = userSigningServices.entrySet().iterator(); + if (!iterator.hasNext()) { + throw new IllegalStateException("No users configured in context"); + } + + Map.Entry firstUserEntry = iterator.next(); + String firstUserName = firstUserEntry.getKey(); + SigningService firstUserService = firstUserEntry.getValue(); + + List canonicalRequestIds = new ArrayList<>(); + List canonicalTxDataHashes = new ArrayList<>(); + List canonicalStateHashes = new ArrayList<>(); + + for (int i = 0; i < commitmentsPerThread; i++) { + byte[] stateBytes = TestUtils.generateRandomBytes(32); + byte[] txData = TestUtils.generateRandomBytes(32); + DataHash stateHash = TestUtils.hashData(stateBytes); + DataHash txDataHash = TestUtils.hashData(txData); + RequestId requestId = TestUtils.createRequestId(firstUserService, stateHash); + + canonicalRequestIds.add(requestId); + canonicalTxDataHashes.add(txDataHash); + canonicalStateHashes.add(stateHash); + } + + // ----------------------------- + // STEP 2: Submit commitments concurrently + // ----------------------------- + for (Map.Entry entry : userSigningServices.entrySet()) { + String userName = entry.getKey(); + SigningService signingService = entry.getValue(); + + for (int i = 0; i < commitmentsPerThread; i++) { + RequestId reusedRequestId = canonicalRequestIds.get(i); + DataHash stateHashToUse; + DataHash txDataHashToUse; + + // For the FIRST user: use canonical (correct) data + if (userName.equals(firstUserName)) { + stateHashToUse = canonicalStateHashes.get(i); + txDataHashToUse = canonicalTxDataHashes.get(i); + } else { + // For all other users: create conflicting data but reuse same RequestId + byte[] newStateBytes = TestUtils.generateRandomBytes(32); + byte[] newTxData = TestUtils.generateRandomBytes(32); + stateHashToUse = TestUtils.hashData(newStateBytes); + txDataHashToUse = TestUtils.hashData(newTxData); + } + + for (int aggIndex = 0; aggIndex < aggregatorClients.size(); aggIndex++) { + AggregatorClient aggregatorClient = aggregatorClients.get(aggIndex); + String aggregatorId = "Aggregator" + aggIndex; + + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + long start = System.nanoTime(); + try { + Authenticator authenticator = + TestUtils.createAuthenticator(signingService, txDataHashToUse, stateHashToUse); + + // Log outgoing request details + System.out.printf("[%s] Sending commitment to %s (user=%s, reusedRequestId=%s)%n", + Thread.currentThread().getName(), aggregatorId, userName, reusedRequestId); + + SubmitCommitmentResponse response = + aggregatorClient.submitCommitment(reusedRequestId, txDataHashToUse, authenticator).get(); + + boolean success = response.getStatus() == SubmitCommitmentStatus.SUCCESS; + long end = System.nanoTime(); + + // Log response details + System.out.printf("[%s] Response from %s (user=%s, success=%s, durationMs=%.2f)%n", + Thread.currentThread().getName(), aggregatorId, userName, success, + (end - start) / 1_000_000.0); + + return new CommitmentResult( + userName + "-" + aggregatorId, + Thread.currentThread().getName(), + reusedRequestId, + success, + start, + end); + } catch (Exception e) { + long end = System.nanoTime(); + System.err.printf("[%s] ERROR submitting to %s (user=%s): %s%n", + Thread.currentThread().getName(), aggregatorId, userName, e.getMessage()); + return new CommitmentResult( + userName + "-" + aggregatorId, + Thread.currentThread().getName(), + reusedRequestId, + false, + start, + end); + } + }, executor); + + futures.add(future); + } + } + } + + // Wait for all submissions to complete + context.setCommitmentFutures(futures); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + executor.shutdown(); + } + @When("I submit all mint commitments concurrently to all aggregators") public void iSubmitAllMintCommitmentsConcurrentlyToAllAggregators() { int threadsCount = context.getConfiguredThreadCount(); @@ -538,6 +818,156 @@ public void allMintCommitmentsShouldReceiveInclusionProofsFromAllAggregatorsWith assertEquals(results.size(), verifiedCount, "All commitments should be verified"); } + @Then("all shard mint commitments should receive inclusion proofs from all aggregators within {int} seconds") + public void allShardMintCommitmentsShouldReceiveInclusionProofsFromAllAggregatorsWithinSeconds(int timeoutSeconds) throws Exception { + List results = helper.collectCommitmentResults(); + + // Verify inclusion proofs for all aggregators in parallel + helper.verifyAllInclusionProofsInParallelForShardAggregators(timeoutSeconds, context.getShardHelper()); + + long verifiedCount = results.stream() + .filter(CommitmentResult::isVerified) + .count(); + + System.out.println("=== Inclusion Proof Verification Results ==="); + System.out.println("Total commitments: " + results.size()); + System.out.println("Verified commitments: " + verifiedCount + " / " + results.size()); + + // Group results by aggregator for detailed reporting + Map> resultsByAggregator = results.stream() + .collect(Collectors.groupingBy(r -> helper.extractAggregatorFromUserName(r.getUserName()))); + + for (Map.Entry> entry : resultsByAggregator.entrySet()) { + String aggregatorId = entry.getKey(); + List aggregatorResults = entry.getValue(); + + long aggregatorVerifiedCount = aggregatorResults.stream() + .filter(CommitmentResult::isVerified) + .count(); + + System.out.println("\n" + aggregatorId + ":"); + System.out.println(" Verified: " + aggregatorVerifiedCount + " / " + aggregatorResults.size()); + + // Print failed ones for this aggregator + aggregatorResults.stream() + .filter(r -> !r.isVerified()) + .forEach(r -> System.out.println( + " ❌ Failed: requestId=" + r.getRequestId().toString() + + ", status=" + (r.getStatus() != null ? r.getStatus() : "Unknown") + + ", user=" + r.getUserName() + )); + + // Print successful ones (optional, for debugging) + if (aggregatorVerifiedCount > 0) { + System.out.println(" ✅ Successfully verified " + aggregatorVerifiedCount + " commitments"); + } + } + + assertEquals(results.size(), verifiedCount, "All commitments should be verified"); + } + + @Given("the aggregator URLs are configured with shard_id_length {int}") + public void configureAggregatorsWithShardIdLength(int shardIdLength) { + Map shardUrlMap = AggregatorConfig.getShardUrlMap(); + AggregatorRequestHelper shardHelper = new AggregatorRequestHelper(shardIdLength, shardUrlMap); + + List urls = new ArrayList<>(shardUrlMap.values()); + context.setAggregatorUrls(urls); + + List clients = new ArrayList<>(); + for (Map.Entry entry : shardUrlMap.entrySet()) { + clients.add(shardHelper.getClientForShard(entry.getKey())); + } + + context.setAggregatorClients(clients); + context.setShardHelper(shardHelper); + + System.out.printf("Configured %d aggregators with shard_id_length=%d%n", + shardUrlMap.size(), shardIdLength); + } + + @When("I submit all mint commitments to correct shards concurrently") + public void submitCommitmentsToCorrectShards() throws Exception { + int threadsCount = context.getConfiguredThreadCount(); + int commitmentsPerThread = context.getConfiguredCommitmentsPerThread(); + AggregatorRequestHelper shardHelper = context.getShardHelper(); + Map userSigningServices = context.getUserSigningServices(); + + ExecutorService executor = Executors.newFixedThreadPool(threadsCount); + List> futures = new ArrayList<>(); + + for (Map.Entry entry : userSigningServices.entrySet()) { + String userName = entry.getKey(); + SigningService signingService = entry.getValue(); + + for (int i = 0; i < commitmentsPerThread; i++) { + byte[] stateBytes = TestUtils.generateRandomBytes(32); + byte[] txData = TestUtils.generateRandomBytes(32); + DataHash stateHash = TestUtils.hashData(stateBytes); + DataHash txDataHash = TestUtils.hashData(txData); + RequestId requestId = TestUtils.createRequestId(signingService, stateHash); + + // Determine correct shard + int shardId = shardHelper.getShardForRequest(requestId); + AggregatorClient aggregatorClient = shardHelper.getClientForShard(shardId); + String aggregatorUrl = shardHelper.getShardUrl(shardId); + + if (aggregatorClient == null) { + System.out.printf("⚠️ No aggregator found for shardId=%d (requestId=%s)%n", shardId, requestId); + continue; + } + + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + long start = System.nanoTime(); + try { + Authenticator authenticator = TestUtils.createAuthenticator(signingService, txDataHash, stateHash); + + System.out.printf("→ [Thread: %s] Sending commitment %s to shard %d (%s)%n", + Thread.currentThread().getName(), requestId, shardId, aggregatorUrl); + + SubmitCommitmentResponse response = aggregatorClient + .submitCommitment(requestId, txDataHash, authenticator).get(); + + boolean success = response.getStatus() == SubmitCommitmentStatus.SUCCESS; + long end = System.nanoTime(); + + shardHelper.getStats(shardId).incrementCommitments(); + if (success) shardHelper.getStats(shardId).incrementSuccess(); + else shardHelper.getStats(shardId).incrementFailures(); + + System.out.printf("← Response from shard %d (%s): %s%n", + shardId, aggregatorUrl, response.getStatus()); + + return new CommitmentResult( + userName + "-Shard" + shardId, + Thread.currentThread().getName(), + requestId, success, start, end + ); + + } catch (Exception e) { + long end = System.nanoTime(); + shardHelper.getStats(shardId).incrementFailures(); + System.err.printf("❌ Error sending to shard %d (%s): %s%n", + shardId, aggregatorUrl, e.getMessage()); + return new CommitmentResult( + userName + "-Shard" + shardId, + Thread.currentThread().getName(), + requestId, false, start, end + ); + } + }, executor); + + futures.add(future); + } + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + executor.shutdown(); + + context.setCommitmentFutures(futures); + shardHelper.printShardStats(); + } + @Then("all mint commitments should receive inclusion proofs within {int} seconds with {int}% success rate") public void allMintCommitmentsShouldReceiveInclusionProofsWithSuccessRate(int timeoutSeconds, int expectedSuccessRate) throws Exception { List results = helper.collectCommitmentResults(); @@ -589,10 +1019,13 @@ public void iShouldSeePerformanceMetricsForEachAggregator() { System.out.println("\n=== 📊 AGGREGATOR PERFORMANCE COMPARISON ==="); // Print detailed breakdown - helper.printDetailedResultsByAggregator(results, aggregatorClients.size()); + helper.printDetailedResultsByAggregator(results, aggregatorClients, context.getAggregatorUrls()); // Additional performance analysis helper.printPerformanceComparison(results, aggregatorClients.size()); + + // Additional performance analysis + helper.printInclusionProofStatistics(results); } @Then("aggregator performance should meet minimum thresholds") @@ -645,4 +1078,286 @@ public void trustBaseIsSet() throws IOException { ); assertNotNull(context.getTrustBase(), "trust-base.json must be set"); } + + @When("{string} splits her token into halves for {string} as {string}") + public void userSplitsHerTokenIntoHalves(String username, String recipient, String splitMintType) throws Exception { + Token originalToken = context.getUserToken(username); + SigningService signingService = helper.getSigningServiceForToken(username, originalToken); + + TokenCoinData originalData = originalToken.getCoins().orElseThrow(() -> new IllegalStateException("Token has no coin data")); + Map originalCoins = originalData.getCoins(); + + Map halfCoins1 = new HashMap<>(); + Map halfCoins2 = new HashMap<>(); + + for (Map.Entry entry : originalCoins.entrySet()) { + BigInteger value = entry.getValue(); + BigInteger half = value.divide(BigInteger.valueOf(2)); + BigInteger remainder = value.mod(BigInteger.valueOf(2)); + halfCoins1.put(entry.getKey(), half); + halfCoins2.put(entry.getKey(), half.add(remainder)); + } + + TokenCoinData data1 = new TokenCoinData(halfCoins1); + TokenCoinData data2 = new TokenCoinData(halfCoins2); + + Token nametagToken = context.getNameTagToken(username); + + TokenSplitBuilder builder = new TokenSplitBuilder(); + TokenSplitBuilder.TokenSplit split = builder + .createToken( + new TokenId(TestUtils.generateRandomBytes(32)), + originalToken.getType(), + null, + data1, + ProxyAddress.create(nametagToken.getId()), + TestUtils.generateRandomBytes(32), + null + ) + .createToken( + new TokenId(TestUtils.generateRandomBytes(32)), + originalToken.getType(), + null, + data2, + ProxyAddress.create(context.getNameTagToken(recipient).getId()), + TestUtils.generateRandomBytes(32), + null + ) + .build(originalToken); + + TransferCommitment burnCommitment = split.createBurnCommitment( + TestUtils.generateRandomBytes(32), + signingService + ); + + SubmitCommitmentResponse burnResponse = context.getClient() + .submitCommitment(burnCommitment).get(); + assertEquals(SubmitCommitmentStatus.SUCCESS, burnResponse.getStatus(), "Burn failed"); + + List> mintCommitments = split.createSplitMintCommitments( + context.getTrustBase(), + burnCommitment.toTransaction( + InclusionProofUtils.waitInclusionProof( + context.getClient(), + context.getTrustBase(), + burnCommitment + ).get() + ) + ); + + List splitTokens = new ArrayList<>(); + + // BUG FIX: TokenSplitBuilder uses HashMap internally — iteration order is + // non-deterministic. Match commitments to recipients by proxy address, + // not list index. (Caused intermittent Scenario 3 failures) + String ownerProxyAddr = ProxyAddress.create(nametagToken.getId()).getAddress(); + Token recipientNametag = context.getNameTagToken(recipient); + MintCommitment ownerCommitment = null; + MintCommitment recipientCommitment = null; + for (MintCommitment mc : mintCommitments) { + if (mc.getTransactionData().getRecipient().getAddress().equals(ownerProxyAddr)) { + ownerCommitment = mc; + } else { + recipientCommitment = mc; + } + } + assertNotNull(ownerCommitment, "Could not find split commitment for owner " + username); + assertNotNull(recipientCommitment, "Could not find split commitment for recipient " + recipient); + + // Process owner's split token + SubmitCommitmentResponse response = context.getClient().submitCommitment(ownerCommitment).get(); + assertEquals(SubmitCommitmentStatus.SUCCESS, response.getStatus(), "Split mint failed for " + username); + + Predicate predicate = UnmaskedPredicate.create( + ownerCommitment.getTransactionData().getTokenId(), + ownerCommitment.getTransactionData().getTokenType(), + SigningService.createFromSecret(context.getUserSecret().get(username)), + HashAlgorithm.SHA256, + ownerCommitment.getTransactionData().getSalt() + ); + + TokenState state = new TokenState(predicate, null); + Token splitToken = Token.create( + context.getTrustBase(), + state, + ownerCommitment.toTransaction( + InclusionProofUtils.waitInclusionProof( + context.getClient(), + context.getTrustBase(), + ownerCommitment + ).get() + ), + List.of(nametagToken) + ); + + assertTrue(splitToken.verify(context.getTrustBase()).isSuccessful(), "Split token invalid for " + username); + splitTokens.add(splitToken); + + // Process recipient's split token + SubmitCommitmentResponse response2 = context.getClient().submitCommitment(recipientCommitment).get(); + assertEquals(SubmitCommitmentStatus.SUCCESS, response2.getStatus(), "Split mint failed for " + recipient); + + Predicate predicate2 = UnmaskedPredicate.create( + recipientCommitment.getTransactionData().getTokenId(), + recipientCommitment.getTransactionData().getTokenType(), + SigningService.createFromSecret(context.getUserSecret().get(recipient)), + HashAlgorithm.SHA256, + recipientCommitment.getTransactionData().getSalt() + ); + + TokenState state2 = new TokenState(predicate2, null); + Token splitToken2 = Token.create( + context.getTrustBase(), + state2, + recipientCommitment.toTransaction( + InclusionProofUtils.waitInclusionProof( + context.getClient(), + context.getTrustBase(), + recipientCommitment + ).get() + ), + List.of(recipientNametag) + ); + + assertTrue(splitToken2.verify(context.getTrustBase()).isSuccessful(), "Split token invalid for " + recipient); + splitTokens.add(splitToken2); + + assertTrue(PredicateEngineService.createPredicate(splitTokens.get(0).getState().getPredicate()) + .isOwner(helper.getSigningServiceForToken(username, splitTokens.get(0)).getPublicKey()), username + " should own the token"); + assertTrue(PredicateEngineService.createPredicate(splitTokens.get(1).getState().getPredicate()) + .isOwner(helper.getSigningServiceForToken(recipient, splitTokens.get(1)).getPublicKey()), recipient + " should own the token"); + + context.removeUserToken(username, originalToken); + // splitTokens[0] is owner's token, splitTokens[1] is recipient's token + context.addUserToken(username, splitTokens.get(0)); + context.addUserToken(recipient, splitTokens.get(1)); + context.setLastSplitTokens(splitTokens); + context.setLastSplitRecipient(recipient); + context.setCurrentUser(username); + } + + @Given("each user have nametags prepared") + public void eachUserHaveNametagsPrepared() throws Exception { + for (Map.Entry entry : context.getUserSigningServices().entrySet()) { + String userName = entry.getKey(); + + // Create a nametag for each user + String nameTagIdentifier = TestUtils.generateRandomString(10); + Token nametagToken = helper.createNameTagTokenForUser( + userName, + context.getUserToken(context.getCurrentUser()), + nameTagIdentifier, + userName + ); + + assertNotNull(nametagToken, "Nametag token should be created for " + userName); + assertTrue(nametagToken.verify(context.getTrustBase()).isSuccessful(), + "Nametag token should be valid for " + userName); + + context.addNameTagToken(userName, nametagToken); + } + } + + @And("{string} transfers one split token to {string}") + public void userTransfersOneSplitTokenToAnother(String fromUser, String toUser) throws Exception { + List tokens = context.getUserTokens().get(fromUser); + assertNotNull(tokens, "No split tokens found for " + fromUser); + assertFalse(tokens.isEmpty(), "No split tokens available"); + + Token tokenToTransfer = tokens.get(0); // send one half + ProxyAddress proxyAddress = ProxyAddress.create( + context.getNameTagToken(toUser).getId() + ); + + helper.transferToken(fromUser, toUser, tokenToTransfer, proxyAddress, null); + } + + @When("{string} transfers the token to direct address {string}") + public void transfersTheTokenToDirectAddress(String fromUser, String directAddress) throws Exception { + Token sourceToken = context.getUserToken(fromUser); + + TransferCommitment transferCommitment = TransferCommitment.create( + sourceToken, + AddressFactory.createAddress(directAddress), + randomBytes(32), + null, + null, + context.getUserSigningServices().get(fromUser) + ); + + SubmitCommitmentResponse response = context.getClient().submitCommitment(transferCommitment).get(); + if (response.getStatus() != SubmitCommitmentStatus.SUCCESS) { + throw new Exception("Failed to submit transfer commitment: " + response.getStatus()); + } + + InclusionProof inclusionProof = waitInclusionProof( + context.getClient(), + context.getTrustBase(), + transferCommitment + ).get(); + transferCommitment.toTransaction(inclusionProof); + } + + // Concurrent stress test steps (replaces TokenE2EConcurrentTest) + private int stressThreadCount; + private int stressIterations; + private List stressDurationsMs; + + @Given("{int} concurrent workers with {int} iterations each") + public void concurrentWorkersWithIterationsEach(int threadCount, int iterations) { + this.stressThreadCount = threadCount; + this.stressIterations = iterations; + } + + @When("all workers execute mint-and-verify flow concurrently") + public void allWorkersExecuteMintAndVerifyFlowConcurrently() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(stressThreadCount); + List>> futures = new ArrayList<>(); + + for (int t = 0; t < stressThreadCount; t++) { + byte[] secret = ("StressWorker" + t).getBytes(); + CompletableFuture> future = CompletableFuture.supplyAsync(() -> { + List durations = new ArrayList<>(); + for (int i = 0; i < stressIterations; i++) { + long start = System.currentTimeMillis(); + try { + Token token = TokenUtils.mintToken( + context.getClient(), + context.getTrustBase(), + secret + ); + assertTrue(token.verify(context.getTrustBase()).isSuccessful()); + } catch (Exception e) { + throw new RuntimeException("Mint failed: " + e.getMessage(), e); + } + durations.add(System.currentTimeMillis() - start); + } + return durations; + }, executor); + futures.add(future); + } + + stressDurationsMs = new ArrayList<>(); + for (CompletableFuture> f : futures) { + stressDurationsMs.addAll(f.get()); + } + + executor.shutdown(); + } + + @Then("performance statistics should be printed") + public void performanceStatisticsShouldBePrinted() { + assertNotNull(stressDurationsMs, "No stress test results"); + assertFalse(stressDurationsMs.isEmpty(), "No durations recorded"); + + long fastest = stressDurationsMs.stream().mapToLong(Long::longValue).min().orElse(0); + long slowest = stressDurationsMs.stream().mapToLong(Long::longValue).max().orElse(0); + double average = stressDurationsMs.stream().mapToLong(Long::longValue).average().orElse(0); + + System.out.println("=== Concurrent Mint Stress Test Results ==="); + System.out.printf("Workers: %d, Iterations: %d, Total mints: %d%n", + stressThreadCount, stressIterations, stressDurationsMs.size()); + System.out.printf("Fastest: %d ms, Slowest: %d ms, Average: %.2f ms%n", + fastest, slowest, average); + } } \ No newline at end of file diff --git a/src/test/java/org/unicitylabs/sdk/e2e/steps/shared/StepHelper.java b/src/test/java/org/unicitylabs/sdk/e2e/steps/shared/StepHelper.java index 09c5c9b..06d7962 100644 --- a/src/test/java/org/unicitylabs/sdk/e2e/steps/shared/StepHelper.java +++ b/src/test/java/org/unicitylabs/sdk/e2e/steps/shared/StepHelper.java @@ -10,6 +10,7 @@ import org.unicitylabs.sdk.e2e.context.TestContext; import org.unicitylabs.sdk.hash.DataHash; import org.unicitylabs.sdk.hash.HashAlgorithm; +import org.unicitylabs.sdk.predicate.SerializablePredicate; import org.unicitylabs.sdk.predicate.embedded.MaskedPredicate; import org.unicitylabs.sdk.predicate.Predicate; import org.unicitylabs.sdk.predicate.embedded.UnmaskedPredicate; @@ -21,6 +22,7 @@ import org.unicitylabs.sdk.token.TokenType; import org.unicitylabs.sdk.transaction.*; import org.unicitylabs.sdk.utils.TestUtils; +import org.unicitylabs.sdk.utils.helpers.AggregatorRequestHelper; import org.unicitylabs.sdk.utils.helpers.CommitmentResult; import java.nio.charset.StandardCharsets; @@ -89,11 +91,9 @@ public Token createNameTagTokenForUser(String username, Token token, String name nametagGenesis ); } + public void transferToken(String fromUser, String toUser, Token token, Address toAddress, String customData) throws Exception { + SigningService fromSigningService = getSigningServiceForToken(fromUser, token); - public void transferToken(String fromUser, String toUser, Token token, Address toAddress, String customData) throws Exception { - SigningService fromSigningService = context.getUserSigningServices().get(fromUser); - - // Create data hash and state data if custom data provided DataHash dataHash = null; byte[] stateData = null; if (customData != null && !customData.isEmpty()) { @@ -101,7 +101,6 @@ public void transferToken(String fromUser, String toUser, Token token, Address t dataHash = TestUtils.hashData(stateData); } - // Submit transfer commitment TransferCommitment transferCommitment = TransferCommitment.create( token, toAddress, @@ -116,21 +115,13 @@ public void transferToken(String fromUser, String toUser, Token token, Address t throw new Exception("Failed to submit transfer commitment: " + response.getStatus()); } - // Wait for inclusion proof - InclusionProof inclusionProof = waitInclusionProof( - context.getClient(), - context.getTrustBase(), - transferCommitment - ).get(); - TransferTransaction transferTransaction = transferCommitment.toTransaction( - inclusionProof - ); + InclusionProof inclusionProof = waitInclusionProof(context.getClient(), context.getTrustBase(), transferCommitment).get(); + TransferTransaction transferTransaction = transferCommitment.toTransaction(inclusionProof); context.savePendingTransfer(toUser, token, transferTransaction); } public void finalizeTransfer(String username, Token token, TransferTransaction tx) throws Exception { - byte[] secret = context.getUserSecret().get(username); Token currentNameTagToken = context.getNameTagToken(username); @@ -139,7 +130,6 @@ public void finalizeTransfer(String username, Token token, TransferTransactio for (Token t : nametagTokens) { String actualNametagAddress = tx.getData().getRecipient().getAddress(); String expectedProxyAddress = ProxyAddress.create(t.getId()).getAddress(); - if (actualNametagAddress.equalsIgnoreCase(expectedProxyAddress)) { currentNameTagToken = t; break; @@ -152,23 +142,17 @@ public void finalizeTransfer(String username, Token token, TransferTransactio additionalTokens.add(currentNameTagToken); } - Predicate unlockPredicate = context.getUserPredicate().get(username); - if (unlockPredicate == null){ - context.getUserSigningServices().put(username, SigningService.createFromSecret(secret)); - unlockPredicate = UnmaskedPredicate.create( - token.getId(), - token.getType(), - context.getUserSigningServices().get(username), - HashAlgorithm.SHA256, - tx.getData().getSalt() - ); - } - TokenState recipientState = new TokenState( - unlockPredicate, - null + + PredicateType predicateType = detectPredicateType(username, token, tx); + Predicate recipientPredicate = createRecipientPredicate( + username, + token, + tx, + predicateType ); + TokenState recipientState = new TokenState(recipientPredicate, null); Token finalizedToken = context.getClient().finalizeTransaction( context.getTrustBase(), token, @@ -177,6 +161,7 @@ public void finalizeTransfer(String username, Token token, TransferTransactio additionalTokens ); + context.getUserSigningServices().put(username, getSigningServiceForToken(username, finalizedToken)); context.addUserToken(username, finalizedToken); } @@ -283,11 +268,24 @@ public List collectCommitmentResults() { .collect(Collectors.toList()); } - // Helper method to extract aggregator info from username + // Modified method to extract shard/aggregator from userName or use shard URL mapping public String extractAggregatorFromUserName(String userName) { if (userName.contains("-Aggregator")) { return userName.substring(userName.indexOf("-Aggregator")); } + if (userName.contains("-Shard")) { + return userName.substring(userName.indexOf("-Shard")); + } + return "Unknown-Aggregator"; + } + + // New method to map shard URL to aggregator index + public String mapShardUrlToAggregator(String shardUrl, List aggregatorUrls) { + for (int i = 0; i < aggregatorUrls.size(); i++) { + if (shardUrl.equals(aggregatorUrls.get(i))) { + return "-Aggregator" + i; + } + } return "Unknown-Aggregator"; } @@ -326,6 +324,60 @@ public void verifyAllInclusionProofsInParallelForMultipleAggregators(int timeout } } + public void verifyAllInclusionProofsInParallelForShardAggregators( + int timeoutSeconds, + AggregatorRequestHelper shardHelper) throws Exception { + + List results = collectCommitmentResults(); + + // Group results by shard ID instead of Aggregator name + Map> resultsByShard = results.stream() + .collect(Collectors.groupingBy(r -> shardHelper.getShardForRequest(r.getRequestId()))); + + ExecutorService executor = Executors.newFixedThreadPool(resultsByShard.size()); + List> verificationFutures = new ArrayList<>(); + + for (Map.Entry> entry : resultsByShard.entrySet()) { + int shardId = entry.getKey(); + List shardResults = entry.getValue(); + AggregatorClient aggregatorClient = shardHelper.getClientForShard(shardId); + String shardUrl = shardHelper.getShardUrl(shardId); + + if (aggregatorClient == null) { + System.out.printf("⚠️ No aggregator found for shard %d, skipping %d results%n", shardId, shardResults.size()); + continue; + } + + CompletableFuture future = CompletableFuture.runAsync(() -> { + try { + System.out.printf("🔍 Verifying inclusion proofs for shard %d (%s)%n", shardId, shardUrl); + verifyInclusionProofsForAggregator(aggregatorClient, shardResults, timeoutSeconds); + + // Update shard-level stats + long verified = shardResults.stream().filter(CommitmentResult::isVerified).count(); + shardHelper.getStats(shardId).incrementCommitments(); + shardHelper.getStats(shardId).incrementSuccessBy((int) verified); + shardHelper.getStats(shardId).incrementFailuresBy(shardResults.size() - (int) verified); + + System.out.printf("✅ Shard %d verification complete: %d/%d verified%n", + shardId, verified, shardResults.size()); + } catch (Exception e) { + System.err.printf("❌ Error verifying shard %d: %s%n", shardId, e.getMessage()); + shardHelper.getStats(shardId).incrementFailures(); + } + }, executor); + + verificationFutures.add(future); + } + + // Wait for all shard verifications to complete + CompletableFuture.allOf(verificationFutures.toArray(new CompletableFuture[0])) + .get(timeoutSeconds + 10, TimeUnit.SECONDS); + + executor.shutdown(); + shardHelper.printShardStats(); + } + private void verifyInclusionProofsForAggregator(AggregatorClient aggregatorClient, List results, int timeoutSeconds) throws Exception { @@ -382,16 +434,47 @@ private void verifyInclusionProofsForAggregator(AggregatorClient aggregatorClien } } - // Method to print detailed results by aggregator - public void printDetailedResultsByAggregator(List results, int aggregatorCount) { - System.out.println("\n=== Detailed Results by Aggregator ==="); + // Modified method to group results by actual shard/aggregator URL + public void printDetailedResultsByAggregator(List results, + List aggregatorClients, + List aggregatorUrls) { + System.out.println("\n=== 📊 AGGREGATOR PERFORMANCE COMPARISON ==="); + System.out.println("\n=== Detailed Results by Shard ==="); - Map> resultsByAggregator = results.stream() + // Group by what's actually in the username (could be -Shard or -Aggregator) + Map> resultsByIdentifier = results.stream() .collect(Collectors.groupingBy(r -> extractAggregatorFromUserName(r.getUserName()))); - for (int i = 0; i < aggregatorCount; i++) { + // Create a map from shard URLs to aggregator indices + Map urlToIndex = new HashMap<>(); + for (int i = 0; i < aggregatorUrls.size(); i++) { + urlToIndex.put(aggregatorUrls.get(i), i); + } + + // Display results for each configured shard/aggregator + for (int i = 0; i < aggregatorUrls.size(); i++) { + String aggregatorUrl = aggregatorUrls.get(i); + + // Try to find results for this aggregator by index String aggregatorId = "-Aggregator" + i; - List aggregatorResults = resultsByAggregator.getOrDefault(aggregatorId, new ArrayList<>()); + String shardId = "-Shard" + i; + + // Collect results from both possible identifiers + List aggregatorResults = new ArrayList<>(); + aggregatorResults.addAll(resultsByIdentifier.getOrDefault(aggregatorId, new ArrayList<>())); + aggregatorResults.addAll(resultsByIdentifier.getOrDefault(shardId, new ArrayList<>())); + + // Also check for any shard identifiers that might map to this URL + // (in case shard numbering doesn't match aggregator numbering) + for (Map.Entry> entry : resultsByIdentifier.entrySet()) { + String identifier = entry.getKey(); + // Skip if we already processed this identifier + if (identifier.equals(aggregatorId) || identifier.equals(shardId)) { + continue; + } + // Check if any result in this group was sent to this URL + // (You'd need to track the URL in CommitmentResult for this to work properly) + } long verifiedCount = aggregatorResults.stream() .filter(CommitmentResult::isVerified) @@ -400,36 +483,119 @@ public void printDetailedResultsByAggregator(List results, int double successRate = aggregatorResults.isEmpty() ? 0 : (double) verifiedCount / aggregatorResults.size() * 100; - // Calculate average inclusion proof time for verified commitments OptionalDouble avgInclusionTime = aggregatorResults.stream() .filter(CommitmentResult::isVerified) .mapToDouble(CommitmentResult::getInclusionDurationMillis) .average(); - System.out.println("Aggregator" + i + " (localhost:" + (3000 + i * 5080) + "):"); + System.out.println("\nShard/Aggregator " + i + " (" + aggregatorUrl + "):"); System.out.println(" Total commitments: " + aggregatorResults.size()); System.out.println(" Verified: " + verifiedCount + " / " + aggregatorResults.size()); System.out.println(" Success rate: " + String.format("%.2f%%", successRate)); if (avgInclusionTime.isPresent()) { System.out.println(" Average inclusion time: " + String.format("%.2f ms", avgInclusionTime.getAsDouble())); + } else if (!aggregatorResults.isEmpty()) { + System.out.println(" ⚠️ No verified commitments to calculate timing"); } - // Print failed verifications - List failed = aggregatorResults.stream() - .filter(r -> !r.isVerified()) - .collect(Collectors.toList()); - - if (!failed.isEmpty()) { - System.out.println(" Failed verifications (" + failed.size() + "):"); - failed.forEach(r -> System.out.println(" ❌ " + r.getRequestId() + - " - " + (r.getStatus() != null ? r.getStatus() : "Unknown error"))); + if (aggregatorResults.isEmpty()) { + System.out.println(" ℹ️ No commitments found for this shard/aggregator"); } else { - System.out.println(" ✅ All commitments verified successfully!"); + List failed = aggregatorResults.stream() + .filter(r -> !r.isVerified()) + .collect(Collectors.toList()); + + if (!failed.isEmpty()) { + System.out.println(" Failed verifications (" + failed.size() + "):"); + failed.forEach(r -> System.out.println(" ❌ " + r.getRequestId() + + " - " + (r.getStatus() != null ? r.getStatus() : "Unknown error"))); + } else { + System.out.println(" ✅ All commitments verified successfully!"); + } } + } + + // Show any unmatched results + System.out.println("\n=== Unmatched Results ==="); + for (Map.Entry> entry : resultsByIdentifier.entrySet()) { + String identifier = entry.getKey(); + + // Skip if it matches our expected patterns + boolean matched = false; + for (int i = 0; i < aggregatorUrls.size(); i++) { + if (identifier.equals("-Aggregator" + i) || identifier.equals("-Shard" + i)) { + matched = true; + break; + } + } + + if (!matched) { + List unmatchedResults = entry.getValue(); + long verifiedCount = unmatchedResults.stream() + .filter(CommitmentResult::isVerified) + .count(); - System.out.println(); + System.out.println(identifier + ":"); + System.out.println(" Total commitments: " + unmatchedResults.size()); + System.out.println(" Verified: " + verifiedCount + " / " + unmatchedResults.size()); + + DoubleSummaryStatistics stats = unmatchedResults.stream() + .filter(CommitmentResult::isVerified) + .mapToDouble(CommitmentResult::getInclusionDurationMillis) + .summaryStatistics(); + + if (stats.getCount() > 0) { + System.out.println(String.format(" Average inclusion time: %.2f ms", stats.getAverage())); + } + } } + + System.out.println("====================================="); + } + + // Add this helper method to your test class or helper class + public void printInclusionProofStatistics(List results) { + // Overall statistics + DoubleSummaryStatistics overallStats = results.stream() + .filter(CommitmentResult::isVerified) + .mapToDouble(CommitmentResult::getInclusionDurationMillis) + .summaryStatistics(); + + System.out.println("\n=== 📊 INCLUSION PROOF TIMING STATISTICS ==="); + + if (overallStats.getCount() > 0) { + System.out.println("Overall Performance:"); + System.out.println(String.format(" Average: %.2f ms (%.2f seconds)", + overallStats.getAverage(), overallStats.getAverage() / 1000)); + System.out.println(String.format(" Minimum: %.2f ms", overallStats.getMin())); + System.out.println(String.format(" Maximum: %.2f ms", overallStats.getMax())); + System.out.println(String.format(" Total verified: %d", overallStats.getCount())); + } + + // Per-aggregator statistics + Map> resultsByAggregator = results.stream() + .collect(Collectors.groupingBy(r -> extractAggregatorFromUserName(r.getUserName()))); + + System.out.println("\n=== Per-Aggregator Timing Statistics ==="); + for (Map.Entry> entry : resultsByAggregator.entrySet()) { + String aggregatorId = entry.getKey(); + List aggregatorResults = entry.getValue(); + + DoubleSummaryStatistics aggregatorStats = aggregatorResults.stream() + .filter(CommitmentResult::isVerified) + .mapToDouble(CommitmentResult::getInclusionDurationMillis) + .summaryStatistics(); + + if (aggregatorStats.getCount() > 0) { + System.out.println(aggregatorId + ":"); + System.out.println(String.format(" Average: %.2f ms", aggregatorStats.getAverage())); + System.out.println(String.format(" Min: %.2f ms", aggregatorStats.getMin())); + System.out.println(String.format(" Max: %.2f ms", aggregatorStats.getMax())); + System.out.println(String.format(" Verified count: %d", aggregatorStats.getCount())); + } + } + System.out.println("=========================================="); } public void printPerformanceComparison(List results, int aggregatorCount) { @@ -484,4 +650,103 @@ public void printPerformanceComparison(List results, int aggre System.out.println("=====================================\n"); } + + // MaskedPredicate uses SHA256(secret + nonce) as private key. + // UnmaskedPredicate uses SHA256(secret). Using the wrong one causes + // signature verification to fail silently. + public SigningService getSigningServiceForToken(String username, Token token) { + byte[] secret = context.getUserSecret().get(username); + SerializablePredicate predicate = token.getState().getPredicate(); + + if (predicate instanceof MaskedPredicate) { + MaskedPredicate maskedPredicate = (MaskedPredicate) predicate; + return SigningService.createFromMaskedSecret(secret, maskedPredicate.getNonce()); + } else { + return SigningService.createFromSecret(secret); + } + } + + public boolean isProxyTransfer(TransferTransaction tx) { + return tx.getData().getRecipient() instanceof ProxyAddress; + } + + public Predicate createRecipientPredicate(String username, Token sourceToken, TransferTransaction tx, PredicateType type) { + byte[] secret = context.getUserSecret().get(username); + byte[] salt = tx.getData().getSalt(); + + switch (type) { + case UNMASKED: + return UnmaskedPredicate.create( + sourceToken.getId(), + sourceToken.getType(), + SigningService.createFromSecret(secret), + HashAlgorithm.SHA256, + salt + ); + case MASKED: + MaskedPredicate srcMasked = (MaskedPredicate) sourceToken.getState().getPredicate(); + return MaskedPredicate.create( + sourceToken.getId(), + sourceToken.getType(), + SigningService.createFromMaskedSecret(secret, srcMasked.getNonce()), + HashAlgorithm.SHA256, + srcMasked.getNonce() + ); + case NAMETAG_AWARE: + default: + if (isProxyTransfer(tx)) { + return UnmaskedPredicate.create( + sourceToken.getId(), + sourceToken.getType(), + SigningService.createFromSecret(secret), + HashAlgorithm.SHA256, + salt + ); + } else { + MaskedPredicate src = (MaskedPredicate) sourceToken.getState().getPredicate(); + return MaskedPredicate.create( + sourceToken.getId(), + sourceToken.getType(), + SigningService.createFromMaskedSecret(secret, src.getNonce()), + HashAlgorithm.SHA256, + src.getNonce() + ); + } + } + } + + // Business rule: proxy (nametag) transfers always resolve to UnmaskedPredicate + // on finalization. Direct transfers match predicate type to the actual recipient + // address used in the transaction. + private PredicateType detectPredicateType(String username, Token token, TransferTransaction tx) { + if (tx.getData().getRecipient() instanceof ProxyAddress) { + // Transfer goes through a name-tag (proxy) system + return PredicateType.NAMETAG_AWARE; + } + + // For direct address transfers, check if the recipient address matches an + // unmasked predicate. This correctly handles the case where the source token + // uses a masked predicate but the transfer was done using an unmasked predicate. + byte[] secret = context.getUserSecret().get(username); + SigningService unmaskedSigning = SigningService.createFromSecret(secret); + DirectAddress unmaskedAddress = UnmaskedPredicateReference.create( + token.getType(), + unmaskedSigning, + HashAlgorithm.SHA256 + ).toAddress(); + + if (unmaskedAddress.getAddress().equalsIgnoreCase(tx.getData().getRecipient().getAddress())) { + return PredicateType.UNMASKED; + } + + Predicate predicate = (Predicate) token.getState().getPredicate(); + + if (predicate instanceof MaskedPredicate) { + // Previous state was masked — continuing the masked ownership chain + return PredicateType.MASKED; + } + + // Default fallback: direct unmasked ownership + return PredicateType.UNMASKED; + } } \ No newline at end of file diff --git a/src/test/java/org/unicitylabs/sdk/integration/TokenIntegrationTest.java b/src/test/java/org/unicitylabs/sdk/integration/TokenIntegrationTest.java index 2775a63..7c213b5 100644 --- a/src/test/java/org/unicitylabs/sdk/integration/TokenIntegrationTest.java +++ b/src/test/java/org/unicitylabs/sdk/integration/TokenIntegrationTest.java @@ -7,6 +7,7 @@ import org.testcontainers.containers.Network; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.DockerImageName; +import org.unicitylabs.sdk.common.CommonTestFlow; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -115,12 +116,6 @@ void testGetBlockHeight() throws Exception { assertNotNull(blockHeight); assertTrue(blockHeight >= 0); } - -// @Test -// @Order(3) -// void testTransferFlow() throws Exception { -// CommonTestFlow.testTransferFlow(client); -// } // // @Test // @Order(4) diff --git a/src/test/java/org/unicitylabs/sdk/utils/LoggingAggregatorClient.java b/src/test/java/org/unicitylabs/sdk/utils/LoggingAggregatorClient.java new file mode 100644 index 0000000..2e7fe6e --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/utils/LoggingAggregatorClient.java @@ -0,0 +1,149 @@ +package org.unicitylabs.sdk.utils; + +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.CompletableFuture; +import org.unicitylabs.sdk.api.AggregatorClient; +import org.unicitylabs.sdk.api.Authenticator; +import org.unicitylabs.sdk.api.InclusionProofRequest; +import org.unicitylabs.sdk.api.InclusionProofResponse; +import org.unicitylabs.sdk.api.RequestId; +import org.unicitylabs.sdk.api.SubmitCommitmentRequest; +import org.unicitylabs.sdk.api.SubmitCommitmentResponse; +import org.unicitylabs.sdk.hash.DataHash; +import org.unicitylabs.sdk.jsonrpc.JsonRpcRequest; +import org.unicitylabs.sdk.serializer.UnicityObjectMapper; + +/** + * A decorating wrapper around {@link AggregatorClient} that adds configurable + * debug logging with full JSON output. Useful for test debugging without + * modifying SDK source files. + * + *

Control output via system properties (all default to {@code false}): + *

    + *
  • {@code sdk.log.commitments} — log submitCommitment request/response JSON
  • + *
  • {@code sdk.log.proofs} — log getInclusionProof request/response JSON
  • + *
  • {@code sdk.log.blockHeight} — log getBlockHeight response
  • + *
  • {@code sdk.log.envelope} — log full JSON-RPC envelope (jsonrpc/id/method/params)
  • + *
  • {@code sdk.log.all} — shortcut to enable all of the above
  • + *
+ * + *

Note: when {@code sdk.log.envelope} is enabled the logged JSON-RPC {@code id} + * is generated locally and will differ from the actual UUID sent by the transport. + * Use the {@code requestId} inside params to match against aggregator logs. + * + *

Example: {@code -Dsdk.log.all=true} or {@code -Dsdk.log.commitments=true -Dsdk.log.envelope=true} + */ +public class LoggingAggregatorClient implements AggregatorClient { + + private static final DateTimeFormatter TIME_FMT = + DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); + + private final AggregatorClient delegate; + + public LoggingAggregatorClient(AggregatorClient delegate) { + this.delegate = delegate; + } + + @Override + public CompletableFuture submitCommitment( + RequestId requestId, + DataHash transactionHash, + Authenticator authenticator + ) { + if (isEnabled("commitments")) { + SubmitCommitmentRequest params = new SubmitCommitmentRequest( + requestId, transactionHash, authenticator, true); + if (isEnabled("envelope")) { + log("submitCommitment REQUEST: %s", + toJson(new JsonRpcRequest("submit_commitment", params))); + } else { + log("submitCommitment REQUEST: %s", params.toJson()); + } + } + + return delegate.submitCommitment(requestId, transactionHash, authenticator) + .whenComplete((response, error) -> { + if (!isEnabled("commitments")) return; + if (error != null) { + log("submitCommitment ERROR: %s", error.getMessage()); + } else { + log("submitCommitment RESPONSE: %s", toJson(response)); + } + }); + } + + @Override + public CompletableFuture getInclusionProof(RequestId requestId) { + if (isEnabled("proofs")) { + InclusionProofRequest params = new InclusionProofRequest(requestId); + if (isEnabled("envelope")) { + log("getInclusionProof REQUEST: %s", + toJson(new JsonRpcRequest("get_inclusion_proof", params))); + } else { + log("getInclusionProof REQUEST: %s", params.toJson()); + } + } + + return delegate.getInclusionProof(requestId) + .whenComplete((response, error) -> { + if (!isEnabled("proofs")) return; + if (error != null) { + log("getInclusionProof ERROR: %s", error.getMessage()); + } else { + log("getInclusionProof RESPONSE: %s", toJson(response)); + } + }); + } + + @Override + public CompletableFuture getBlockHeight() { + if (isEnabled("blockHeight")) { + if (isEnabled("envelope")) { + log("getBlockHeight REQUEST: %s", + toJson(new JsonRpcRequest("get_block_height", java.util.Map.of()))); + } else { + log("getBlockHeight REQUEST"); + } + } + + return delegate.getBlockHeight() + .whenComplete((height, error) -> { + if (!isEnabled("blockHeight")) return; + if (error != null) { + log("getBlockHeight ERROR: %s", error.getMessage()); + } else { + log("getBlockHeight RESPONSE: %d", height); + } + }); + } + + public AggregatorClient getDelegate() { + return delegate; + } + + private static boolean isEnabled(String feature) { + return Boolean.getBoolean("sdk.log.all") + || Boolean.getBoolean("sdk.log." + feature); + } + + private static void log(String format, Object... args) { + System.out.printf("[%s] [SDK] " + format + "%n", + prependTimestamp(args)); + } + + private static Object[] prependTimestamp(Object[] args) { + Object[] result = new Object[args.length + 1]; + result[0] = LocalTime.now().format(TIME_FMT); + System.arraycopy(args, 0, result, 1, args.length); + return result; + } + + private static String toJson(Object obj) { + try { + return UnicityObjectMapper.JSON.writeValueAsString(obj); + } catch (Exception e) { + return ""; + } + } +} diff --git a/src/test/java/org/unicitylabs/sdk/utils/TestUtils.java b/src/test/java/org/unicitylabs/sdk/utils/TestUtils.java index bc35e1f..690f9d0 100644 --- a/src/test/java/org/unicitylabs/sdk/utils/TestUtils.java +++ b/src/test/java/org/unicitylabs/sdk/utils/TestUtils.java @@ -3,7 +3,9 @@ import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; +import java.util.HashMap; import java.util.List; +import java.util.Collections; import java.util.Map; import org.unicitylabs.sdk.StateTransitionClient; @@ -35,7 +37,53 @@ public class TestUtils { private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; private static final SecureRandom RANDOM = new SecureRandom(); - + private static final String FIXED_TOKEN_TYPE_HEX = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509"; + private static final String FIXED_COIN_ID_HEX = "455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89"; + + /** + * Converts a hex string to bytes. + */ + public static byte[] hexToBytes(String hex) { + int len = hex.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) + ((Character.digit(hex.charAt(i), 16) << 4) + + Character.digit(hex.charAt(i + 1), 16)); + } + return data; + } + + /** + * Returns a fixed TokenType using the given HEX constant. + */ + public static TokenType fixedTokenType() { + return new TokenType(hexToBytes(FIXED_TOKEN_TYPE_HEX)); + } + + /** + * Returns a fixed CoinId using the given HEX constant. + */ + public static CoinId fixedCoinId() { + return new CoinId(hexToBytes(FIXED_COIN_ID_HEX)); + } + + /** + * Creates TokenCoinData with a fixed CoinId and specified amount. + * @param amount Coin amount (BigInteger) + */ + public static TokenCoinData fixedCoinData(BigInteger amount) { + Map coins = Collections.singletonMap(fixedCoinId(), amount); + return new TokenCoinData(coins); + } + + /** + * Convenience overload to specify amount as long. + */ + public static TokenCoinData fixedCoinData(long amount) { + return fixedCoinData(BigInteger.valueOf(amount)); + } + /** * Generate random bytes of specified length. */ @@ -173,25 +221,36 @@ public static Token transferToken( } /** - * Creates random coin data with specified number of coins + * Creates random coin data with specified number of coins. */ public static TokenCoinData createRandomCoinData(int coinCount) { - Map coins = new java.util.HashMap<>(); - for (int i = 0; i < coinCount; i++) { - CoinId coinId = new CoinId(randomBytes(32)); - BigInteger value = BigInteger.valueOf(SECURE_RANDOM.nextInt(1000) + 100); // Random value between 100-1099 + return randomCoinData(coinCount); + } + + public static TokenCoinData createCoinDataFromTable(List> coinRows) { + Map coins = new HashMap<>(); + + for (Map row : coinRows) { + String idHex = row.get("id"); + String valueStr = row.get("value"); + + // Convert hex ID → CoinId + CoinId coinId = new CoinId(idHex.getBytes(StandardCharsets.UTF_8)); + + // Convert value → BigInteger + BigInteger value = new BigInteger(valueStr); + coins.put(coinId, value); } + return new TokenCoinData(coins); } /** - * Generates random bytes of specified length + * Generates random bytes of specified length. */ public static byte[] generateRandomBytes(int length) { - byte[] bytes = new byte[length]; - SECURE_RANDOM.nextBytes(bytes); - return bytes; + return randomBytes(length); } /** diff --git a/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java b/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java index 6ccff5d..b692fce 100644 --- a/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java +++ b/src/test/java/org/unicitylabs/sdk/utils/TokenUtils.java @@ -18,9 +18,7 @@ import org.unicitylabs.sdk.token.TokenState; import org.unicitylabs.sdk.token.TokenType; import org.unicitylabs.sdk.token.fungible.TokenCoinData; -import org.unicitylabs.sdk.transaction.InclusionProof; -import org.unicitylabs.sdk.transaction.MintCommitment; -import org.unicitylabs.sdk.transaction.MintTransaction; +import org.unicitylabs.sdk.transaction.*; import org.unicitylabs.sdk.util.InclusionProofUtils; public class TokenUtils { @@ -69,8 +67,7 @@ public static Token mintToken( Address address = predicate.getReference().toAddress(); TokenState tokenState = new TokenState(predicate, null); - MintCommitment commitment = MintCommitment.create( - new MintTransaction.Data<>( + MintTransaction.Data transactionData = new MintTransaction.Data<>( tokenId, tokenType, tokenData, @@ -79,10 +76,12 @@ public static Token mintToken( salt, dataHash, null - ) ); - // Submit mint transaction using StateTransitionClient + MintCommitment commitment = MintCommitment.create( + transactionData + ); + SubmitCommitmentResponse response = client .submitCommitment(commitment) .get(); @@ -185,4 +184,4 @@ public static Token mintNametagToken( commitment.toTransaction(inclusionProof) ); } -} +} \ No newline at end of file diff --git a/src/test/java/org/unicitylabs/sdk/utils/helpers/AggregatorRequestHelper.java b/src/test/java/org/unicitylabs/sdk/utils/helpers/AggregatorRequestHelper.java new file mode 100644 index 0000000..789fd33 --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/utils/helpers/AggregatorRequestHelper.java @@ -0,0 +1,135 @@ +package org.unicitylabs.sdk.utils.helpers; + +import org.unicitylabs.sdk.api.*; +import org.unicitylabs.sdk.hash.DataHash; + +import java.math.BigInteger; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +public class AggregatorRequestHelper { + private final int shardIdLength; + private final Map shardMap = new HashMap<>(); + private final Map shardUrls = new HashMap<>(); + private final Map shardStats = new HashMap<>(); + + public AggregatorRequestHelper(int shardIdLength, List clients, List urls) { + this.shardIdLength = shardIdLength; + + int baseId = 1 << shardIdLength; // e.g. 4 for shardIdLength=2 + for (int i = 0; i < clients.size(); i++) { + int shardId = baseId + i; // 4,5,6,7 for length=2 + shardMap.put(shardId, clients.get(i)); + shardUrls.put(shardId, urls.get(i)); + shardStats.put(shardId, new ShardStats()); + } + } + + /** + * Constructs from an explicit shard-to-URL mapping. Each shard ID maps directly + * to its aggregator URL — no index-based assumptions. + * + * @param shardIdLength number of LSBs used for shard routing + * @param shardUrlMap explicit {shardId -> url} mapping + */ + public AggregatorRequestHelper(int shardIdLength, Map shardUrlMap) { + this.shardIdLength = shardIdLength; + + int baseId = 1 << shardIdLength; + int expectedCount = 1 << shardIdLength; + for (int i = 0; i < expectedCount; i++) { + int shardId = baseId + i; + String url = shardUrlMap.get(shardId); + if (url == null) { + throw new IllegalArgumentException( + "Missing URL for shard ID " + shardId + + ". Expected all shard IDs from " + baseId + " to " + (baseId + expectedCount - 1)); + } + shardMap.put(shardId, new org.unicitylabs.sdk.utils.LoggingAggregatorClient( + new org.unicitylabs.sdk.api.JsonRpcAggregatorClient(url))); + shardUrls.put(shardId, url); + shardStats.put(shardId, new ShardStats()); + } + } + + public String getShardUrl(int shardId) { + return shardUrls.getOrDefault(shardId, "unknown"); + } + + public ShardStats getStats(int shardId) { + return shardStats.get(shardId); + } + + private int getShardIdFromClient(AggregatorClient client) { + // Optional: parse from client URL like ...:300X + return shardMap.entrySet().stream() + .filter(e -> e.getValue().equals(client)) + .map(Map.Entry::getKey) + .findFirst().orElse(-1); + } + + // Shard ID = (1 << shardIdLength) | (requestId_hash_LSBs & mask). + // Configuration uses explicit shard-to-URL mapping (sdk.shard..url), + // so URL ordering is irrelevant. All shards from baseId to baseId+2^N-1 + // must be configured. + public int getShardForRequest(RequestId requestId) { + byte[] imprint = requestId.getImprint(); + BigInteger idNum = new BigInteger(1, imprint); + int shardBits = idNum.intValue() & ((1 << shardIdLength) - 1); + return (1 << shardIdLength) | shardBits; + } + + public SubmitCommitmentResponse sendCommitment( + RequestId requestId, DataHash txDataHash, Authenticator auth) throws Exception { + + int shardId = ShardRoutingUtils.getShardForRequest(requestId, shardIdLength); + AggregatorClient client = shardMap.get(shardId); + shardStats.get(shardId).incrementCommitments(); + + System.out.printf("→ Sending commitment to shard %d (%s)%n", shardId, getShardUrl(shardId)); + + SubmitCommitmentResponse response = client.submitCommitment(requestId, txDataHash, auth).get(); + if (response.getStatus() == SubmitCommitmentStatus.SUCCESS) + shardStats.get(shardId).incrementSuccess(); + else + shardStats.get(shardId).incrementFailures(); + + return response; + } + + public void printShardStats() { + System.out.println("\n=== Shard statistics ==="); + shardStats.forEach((id, s) -> System.out.printf( + "Shard %d → total: %d, success: %d, failed: %d%n", + id, s.getTotal(), s.getSuccess(), s.getFailures())); + } + + public static class ShardStats { + private final AtomicInteger total = new AtomicInteger(); + private final AtomicInteger success = new AtomicInteger(); + private final AtomicInteger failures = new AtomicInteger(); + + public void incrementCommitments() { total.incrementAndGet(); } + public void incrementSuccess() { success.incrementAndGet(); } + public void incrementFailures() { failures.incrementAndGet(); } + + public void incrementSuccessBy(int n) { success.addAndGet(n); } + public void incrementFailuresBy(int n) { failures.addAndGet(n); } + + + public int getTotal() { return total.get(); } + public int getSuccess() { return success.get(); } + public int getFailures() { return failures.get(); } + } + + public int getShardIdLength() { + return shardIdLength; + } + + public AggregatorClient getClientForShard(int shardId) { + return shardMap.get(shardId); + } +} \ No newline at end of file diff --git a/src/test/java/org/unicitylabs/sdk/utils/helpers/ShardRoutingUtils.java b/src/test/java/org/unicitylabs/sdk/utils/helpers/ShardRoutingUtils.java new file mode 100644 index 0000000..6007cbe --- /dev/null +++ b/src/test/java/org/unicitylabs/sdk/utils/helpers/ShardRoutingUtils.java @@ -0,0 +1,29 @@ +package org.unicitylabs.sdk.utils.helpers; + +import org.unicitylabs.sdk.api.AggregatorClient; +import org.unicitylabs.sdk.api.RequestId; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +public class ShardRoutingUtils { + + public static int getShardForRequest(RequestId requestId, int shardIdLength) { + // Interpret requestId as hex -> BigInteger + BigInteger idNum = new BigInteger(requestId.toString(), 16); + int shardBits = idNum.intValue() & ((1 << shardIdLength) - 1); + // valid shard IDs start at (1 << shardIdLength) + return (1 << shardIdLength) | shardBits; + } + + public static AggregatorClient selectAggregatorForRequest( + RequestId requestId, + int shardIdLength, + List aggregators, + Map shardMap) { + + int shardId = getShardForRequest(requestId, shardIdLength); + return shardMap.getOrDefault(shardId, aggregators.get(0)); // fallback + } +} \ No newline at end of file diff --git a/src/test/resources/org/unicitylabs/sdk/features/advanced-token-scenarios.feature b/src/test/resources/org/unicitylabs/sdk/features/advanced-token-scenarios.feature index eb0f99b..7824bae 100644 --- a/src/test/resources/org/unicitylabs/sdk/features/advanced-token-scenarios.feature +++ b/src/test/resources/org/unicitylabs/sdk/features/advanced-token-scenarios.feature @@ -36,13 +36,13 @@ Feature: Advanced Token Scenarios When the token is transferred through the chain of existing users And each transfer includes custom data validation Then the final token should maintain original properties - And all intermediate transfers should be recorded correctly + And the transfer chain should have participants from "" to "" And the token should have transfers in history Examples: - | coinValue | expectedTransfers | - | 1000 | 3 | - | 5000 | 3 | + | coinValue | expectedTransfers | expectedChainSize | startUser | endUser | + | 1000 | 3 | 4 | Alice | Dave | + | 5000 | 3 | 4 | Alice | Dave | @nametag-scenarios @reset diff --git a/src/test/resources/org/unicitylabs/sdk/features/authorization.feature b/src/test/resources/org/unicitylabs/sdk/features/authorization.feature new file mode 100644 index 0000000..f1a12fb --- /dev/null +++ b/src/test/resources/org/unicitylabs/sdk/features/authorization.feature @@ -0,0 +1,52 @@ +@authorization +Feature: Token Ownership Authorization + As a developer using the Unicity SDK + I want to verify that only token owners can perform operations + So that unauthorized transfers and operations are prevented + + Background: + Given the aggregator URL is configured + And trust-base.json is set + And the state transition client is initialized + And the following users are set up with their signing services + | name | + | Alice | + | Bob | + | Carol | + | Dave | + + Scenario Outline: Non-owner cannot transfer token owned by + Given "" mints a token with random coin data + And each user have nametags prepared + When "" attempts to transfer ""'s token to "" using a proxy address + Then the unauthorized transfer should fail + + Examples: + | owner | attacker | recipient | + | Alice | Bob | Carol | + | Alice | Carol | Dave | + | Bob | Alice | Carol | + | Bob | Carol | Dave | + + Scenario Outline: Previous owner cannot reclaim transferred token via + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" transfers the token to "Bob" using + And "Bob" finalizes all received tokens + Then "Bob" should own the token successfully + When "Alice" attempts to transfer "Bob"'s token to "Carol" using a proxy address + Then the unauthorized transfer should fail + + Examples: + | transferType | + | a proxy address | + | an unmasked predicate | + + Scenario: Non-owner cannot transfer token received by another user + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" transfers the token to "Bob" using a proxy address + And "Bob" finalizes all received tokens + Then "Bob" should own the token successfully + When "Carol" attempts to transfer "Bob"'s token to "Dave" using a proxy address + Then the unauthorized transfer should fail diff --git a/src/test/resources/org/unicitylabs/sdk/features/edge-cases.feature b/src/test/resources/org/unicitylabs/sdk/features/edge-cases.feature new file mode 100644 index 0000000..ef2ad9c --- /dev/null +++ b/src/test/resources/org/unicitylabs/sdk/features/edge-cases.feature @@ -0,0 +1,71 @@ +@edge-cases +Feature: Edge Cases and Serialization + As a developer using the Unicity SDK + I want to verify edge cases in token operations + So that tokens remain valid under unusual conditions + + Background: + Given the aggregator URL is configured + And trust-base.json is set + And the state transition client is initialized + And the following users are set up with their signing services + | name | + | Alice | + | Bob | + | Carol | + | Dave | + + Scenario: CBOR serialization round-trip preserves minted token + Given "Alice" mints a token with random coin data + When the token for "Alice" is exported to CBOR and imported back + Then the imported token should have the same ID as the original + And the imported token should have the same type as the original + And the imported token should pass verification + + Scenario Outline: CBOR round-trip after transfer via + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" transfers the token to "Bob" using + And "Bob" finalizes all received tokens + Then "Bob" should own the token successfully + When the token for "Bob" is exported to CBOR and imported back + Then the imported token should have the same ID as the original + And the imported token should pass verification + And the imported token should have 1 transactions in its history + + Examples: + | transferType | + | a proxy address | + | an unmasked predicate | + + Scenario: CBOR round-trip after multi-hop transfer + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" transfers the token to "Bob" using a proxy address + And "Bob" finalizes all received tokens + When "Bob" transfers the token to "Carol" using an unmasked predicate + And "Carol" finalizes all received tokens + When the token for "Carol" is exported to CBOR and imported back + Then the imported token should have the same ID as the original + And the imported token should pass verification + And the imported token should have 2 transactions in its history + + Scenario Outline: Long transfer chain preserves token integrity via + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" transfers the token to "Bob" using + And "Bob" finalizes all received tokens + When "Bob" transfers the token to "Carol" using + And "Carol" finalizes all received tokens + When "Carol" transfers the token to "Dave" using + And "Dave" finalizes all received tokens + When "Dave" transfers the token to "Alice" using + And "Alice" finalizes all received tokens + Then "Alice" should own the token successfully + And the token for "Alice" should have 4 transactions in its history + And the token for "Alice" should maintain its original ID and type from "Alice" + + Examples: + | transferType | + | a proxy address | + | an unmasked predicate | diff --git a/src/test/resources/org/unicitylabs/sdk/features/multi-level-split.feature b/src/test/resources/org/unicitylabs/sdk/features/multi-level-split.feature new file mode 100644 index 0000000..f96dd08 --- /dev/null +++ b/src/test/resources/org/unicitylabs/sdk/features/multi-level-split.feature @@ -0,0 +1,37 @@ +@multi-level-split +Feature: Multi-Level Token Split Tree + As a developer using the Unicity SDK + I want to verify that split tokens can be further split and transferred + So that multi-level token trees maintain integrity + + Background: + Given the aggregator URL is configured + And trust-base.json is set + And the state transition client is initialized + And the following users are set up with their signing services + | name | + | Alice | + | Bob | + | Carol | + | Dave | + + Scenario: Split then transfer split parts to different users + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" splits her token into halves for "Carol" as "Unmasked" + And "Alice" transfers one split token to "Bob" + And "Bob" finalizes all received tokens + Then "Bob" should own the token successfully + When "Bob" transfers the token to "Dave" using an unmasked predicate + And "Dave" finalizes all received tokens + Then "Dave" should own the token successfully + + Scenario: Double-spend prevention on split tokens + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" splits her token into halves for "Carol" as "Unmasked" + And "Alice" transfers one split token to "Bob" + And "Bob" finalizes all received tokens + Then "Bob" should own the token successfully + When "Alice" attempts to double-spend the split token to "Dave" + Then the double-spend attempt should be rejected diff --git a/src/test/resources/org/unicitylabs/sdk/features/multiple-aggregators.feature b/src/test/resources/org/unicitylabs/sdk/features/multiple-aggregators.feature index 06bf503..5ea15f0 100644 --- a/src/test/resources/org/unicitylabs/sdk/features/multiple-aggregators.feature +++ b/src/test/resources/org/unicitylabs/sdk/features/multiple-aggregators.feature @@ -1,10 +1,73 @@ -Feature: Bulk Commitment Testing with Multiple Aggregators +@multi-aggregator +Feature: Multiple Aggregator Operations + @shard-routing + Scenario: Submit commitments to correct shard aggregators + Given the aggregator URLs are configured with shard_id_length 1 + And trust-base.json is set + And the aggregator clients are initialized + And I configure 10 threads with 10 commitments each + When I submit all mint commitments to correct shards concurrently + Then all shard mint commitments should receive inclusion proofs from all aggregators within 300 seconds + And I should see performance metrics for each aggregator + + @performance @ignore Scenario: Submit commitments to multiple aggregators concurrently Given the aggregator URLs are configured + | http://localhost:3000 | + | http://localhost:3001 | + | http://localhost:3002 | + | http://localhost:3003 | + | http://localhost:3004 | + | http://localhost:3005 | And trust-base.json is set And the aggregator clients are initialized - And I configure 10 threads with 10 commitments each + And I configure 1 threads with 10 commitments each When I submit all mint commitments concurrently to all aggregators + Then all mint commitments should receive inclusion proofs from all aggregators within 1380 seconds + And I should see performance metrics for each aggregator + + @performance @ignore + Scenario: Submit conflicting commitments to multiple aggregators concurrently + Given the aggregator URLs are configured + | http://localhost:3000 | + | http://localhost:3001 | + | http://localhost:3002 | + | http://localhost:3003 | + | http://localhost:3004 | + | http://localhost:3005 | + And trust-base.json is set + And the aggregator clients are initialized + And I configure 100 threads with 100 commitments each + When I submit conflicting mint commitments concurrently to all aggregators Then all mint commitments should receive inclusion proofs from all aggregators within 30 seconds - And I should see performance metrics for each aggregator \ No newline at end of file + And I should see performance metrics for each aggregator + + @performance @ignore + Scenario: Submit mixed valid and conflicting commitments concurrently + Given the aggregator URLs are configured + | http://localhost:3000 | + | http://localhost:3001 | + | http://localhost:3002 | + | http://localhost:3003 | + | http://localhost:3004 | + | http://localhost:3005 | + And trust-base.json is set + And the aggregator clients are initialized + And I configure 2 threads with 1 commitments each + When I submit mixed valid and conflicting commitments concurrently to all aggregators + Then all mint commitments should receive inclusion proofs from all aggregators within 30 seconds + And I should see performance metrics for each aggregator + + @performance @ignore + Scenario Outline: Concurrent token minting stress test + Given the aggregator URL is configured + And trust-base.json is set + And the state transition client is initialized + And concurrent workers with iterations each + When all workers execute mint-and-verify flow concurrently + Then performance statistics should be printed + + Examples: + | threadCount | iterations | + | 100 | 10 | diff --git a/src/test/resources/org/unicitylabs/sdk/features/predicate-matrix.feature b/src/test/resources/org/unicitylabs/sdk/features/predicate-matrix.feature new file mode 100644 index 0000000..901285e --- /dev/null +++ b/src/test/resources/org/unicitylabs/sdk/features/predicate-matrix.feature @@ -0,0 +1,50 @@ +@predicate-matrix +Feature: Predicate Type Combination Matrix + As a developer using the Unicity SDK + I want to verify all combinations of transfer types work correctly + So that mixed predicate chains are fully supported + + Background: + Given the aggregator URL is configured + And trust-base.json is set + And the state transition client is initialized + And the following users are set up with their signing services + | name | + | Alice | + | Bob | + | Carol | + | Dave | + + Scenario Outline: Two-hop transfer with then + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" transfers the token to "Bob" using + And "Bob" finalizes all received tokens + Then "Bob" should own the token successfully + When "Bob" transfers the token to "Carol" using + And "Carol" finalizes all received tokens + Then "Carol" should own the token successfully + And the token for "Carol" should have 2 transactions in its history + + Examples: + | firstTransferType | secondTransferType | + | a proxy address | a proxy address | + | a proxy address | an unmasked predicate | + | an unmasked predicate | a proxy address | + | an unmasked predicate | an unmasked predicate | + + Scenario Outline: Three-hop mixed predicate chain + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" transfers the token to "Bob" using + And "Bob" finalizes all received tokens + When "Bob" transfers the token to "Carol" using + And "Carol" finalizes all received tokens + When "Carol" transfers the token to "Dave" using + And "Dave" finalizes all received tokens + Then "Dave" should own the token successfully + + Examples: + | hop1 | hop2 | hop3 | + | a proxy address | an unmasked predicate | a proxy address | + | an unmasked predicate | a proxy address | an unmasked predicate | diff --git a/src/test/resources/org/unicitylabs/sdk/features/split-boundaries.feature b/src/test/resources/org/unicitylabs/sdk/features/split-boundaries.feature new file mode 100644 index 0000000..072539c --- /dev/null +++ b/src/test/resources/org/unicitylabs/sdk/features/split-boundaries.feature @@ -0,0 +1,32 @@ +@split-boundaries +Feature: Token Split Boundary Conditions + As a developer using the Unicity SDK + I want to verify split operations handle edge cases correctly + So that invalid splits are rejected and valid splits produce correct results + + Background: + Given the aggregator URL is configured + And trust-base.json is set + And the state transition client is initialized + And the following users are set up with their signing services + | name | + | Alice | + | Bob | + | Carol | + | Dave | + + Scenario: Split token into equal halves preserves total value + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" splits her token into halves for "Bob" as "Unmasked" + Then both split tokens should be valid + And the split token values should sum to the original value + + Scenario: Split token and transfer both halves to different users + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" splits her token into halves for "Carol" as "Unmasked" + And "Alice" transfers one split token to "Bob" + And "Bob" finalizes all received tokens + Then "Bob" should own the token successfully + And "Carol" should own 1 tokens diff --git a/src/test/resources/org/unicitylabs/sdk/features/token-lifecycle.feature b/src/test/resources/org/unicitylabs/sdk/features/token-lifecycle.feature new file mode 100644 index 0000000..571241b --- /dev/null +++ b/src/test/resources/org/unicitylabs/sdk/features/token-lifecycle.feature @@ -0,0 +1,50 @@ +@token-lifecycle +Feature: Token Lifecycle State Transitions + As a developer using the Unicity SDK + I want to verify that tokens follow correct state transitions + So that burned and spent tokens cannot be reused + + Background: + Given the aggregator URL is configured + And trust-base.json is set + And the state transition client is initialized + And the following users are set up with their signing services + | name | + | Alice | + | Bob | + | Carol | + | Dave | + + Scenario: Burned token cannot be transferred after split + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" splits her token into halves for "Bob" as "Unmasked" + Then "Alice" should not be able to transfer the burned token to "Carol" + + Scenario: Pre-transfer token reference cannot be reused + Given "Alice" mints a token with random coin data + And each user have nametags prepared + And "Alice" saves a reference to the current token + When "Alice" transfers the token to "Bob" using a proxy address + And "Bob" finalizes all received tokens + Then "Bob" should own the token successfully + When "Alice" attempts to reuse the saved token reference to transfer to "Carol" + Then the reuse attempt should be rejected + + Scenario Outline: Token state is preserved through transfer chain + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" transfers the token to "Bob" using + And "Bob" finalizes all received tokens + When "Bob" transfers the token to "Carol" using + And "Carol" finalizes all received tokens + When "Carol" transfers the token to "Dave" using + And "Dave" finalizes all received tokens + Then "Dave" should own the token successfully + And the token for "Dave" should have 3 transactions in its history + And the token for "Dave" should maintain its original ID and type from "Alice" + + Examples: + | transferType | + | a proxy address | + | an unmasked predicate | diff --git a/src/test/resources/org/unicitylabs/sdk/features/token-transfer.feature b/src/test/resources/org/unicitylabs/sdk/features/token-transfer.feature index 8990274..1793b53 100644 --- a/src/test/resources/org/unicitylabs/sdk/features/token-transfer.feature +++ b/src/test/resources/org/unicitylabs/sdk/features/token-transfer.feature @@ -13,6 +13,25 @@ Feature: Token Transfer Operations | Alice | | Bob | | Carol | + | Dave | + + Scenario Outline: Double spend attempt + Given "Alice" mints a token with random coin data + And each user have nametags prepared + When "Alice" transfers the token to "Bob" using + And "Bob" finalizes all received tokens + Then "Bob" should own the token successfully + When "Alice" attempts to double-spend the original token to "Carol" using + Then the double-spend attempt should be rejected + And "Carol" should not own any tokens + When "Bob" transfers the token to "Dave" using + And "Dave" finalizes all received tokens + Then "Dave" should own the token successfully + + Examples: + | transferType | + | a proxy address | + | an unmasked predicate | Scenario: Complete token transfer flow from Alice to Bob to Carol Given "Alice" mints a token with random coin data @@ -27,6 +46,60 @@ Feature: Token Transfer Operations Then "Carol" should own the token successfully And the token should have 2 transactions in its history + Scenario Outline: Complete token transfer flow from Alice to Bob to Carol with multiple coins + Given user "Alice" with nonce of 32 bytes + When the "Alice" mints a token of type "" with coins data desctribed below + | name | symbol | id | decimals | value | + | unicity | UCT | 455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89 | 18 | 100 | + | solana | SOL | dee5f8ce778562eec90e9c38a91296a023210ccc76ff4c29d527ac3eb64ade93 | 9 | 100 | + | bitcoin | BTC | 86bc190fcf7b2d07c6078de93db803578760148b16d4431aa2f42a3241ff0daa | 8 | 100 | + | ethereum | ETH | 3c2450f2fd867e7bb60c6a69d7ad0e53ce967078c201a3ecaa6074ed4c0deafb | 18 | 100 | + | tether | USDT | 40d25444648418fe7efd433e147187a3a6adf049ac62bc46038bda5b960bf690 | 6 | 100 | + | usd-coin | USDC | 2265121770fa6f41131dd9a6cc571e28679263d09a53eb2642e145b5b9a5b0a2 | 6 | 100 | + +# Given "Alice" mints a token with random coin data + And user "Bob" create a nametag token with custom data "Bob Custom data" + When "Alice" transfers the token to "Bob" using a proxy address + And "Bob" finalizes all received tokens + Then "Bob" should own the token successfully + And all "Bob" nametag tokens should remain valid + And the token should maintain its original ID and type + When "Bob" transfers the token to "Carol" using an unmasked predicate + And "Carol" finalizes all received tokens + Then "Carol" should own the token successfully + And the token should have 2 transactions in its history + + Examples: + | tokenType | + | f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509 | + + Scenario Outline: Complete token transfer flow to all users with splitting + When the "Alice" mints a token of type "" with coins data desctribed below + | name | symbol | id | decimals | value | + | unicity | UCT | 455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89 | 18 | 100 | + | solana | SOL | dee5f8ce778562eec90e9c38a91296a023210ccc76ff4c29d527ac3eb64ade93 | 9 | 100 | + | bitcoin | BTC | 86bc190fcf7b2d07c6078de93db803578760148b16d4431aa2f42a3241ff0daa | 8 | 100 | + | ethereum | ETH | 3c2450f2fd867e7bb60c6a69d7ad0e53ce967078c201a3ecaa6074ed4c0deafb | 18 | 100 | + | tether | USDT | 40d25444648418fe7efd433e147187a3a6adf049ac62bc46038bda5b960bf690 | 6 | 100 | + | usd-coin | USDC | 2265121770fa6f41131dd9a6cc571e28679263d09a53eb2642e145b5b9a5b0a2 | 6 | 100 | + + And each user have nametags prepared + When "Alice" splits her token into halves for "Carol" as "" + And "Alice" transfers one split token to "Bob" + And "Bob" finalizes all received tokens + Then "Bob" should own the token successfully + And all "Bob" nametag tokens should remain valid + And the token should maintain its original ID and type + When "Bob" transfers the token to "Carol" using an unmasked predicate + And "Carol" finalizes all received tokens + Then "Carol" should own the token successfully + And the token should have 2 transactions in its history + + Examples: + | tokenType | predicateTypeSplit | + | f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509 | Unmasked | +# | f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509 | Masked | + Scenario Outline: Token minting with different configurations Given user "" with nonce of bytes When the user mints a token of type "" with coin data containing coins @@ -49,4 +122,16 @@ Feature: Token Transfer Operations Examples: | user | nametagData | | Bob | Bob's Address | - | Alice | Alice's Tag | \ No newline at end of file + | Alice | Alice's Tag | + + Scenario Outline: Token transfer flow from Alice to subscription service with specific coin + Given user "Alice" with nonce of 32 bytes + When the "Alice" mints a token of type "" with coins data desctribed below + | name | symbol | id | decimals | value | + | unicity | UCT | 455ad8720656b08e8dbd5bac1f3c73eeea5431565f6c1c3af742b1aa12d41d89 | 18 | 1000 | + +# Given "Alice" mints a token with random coin data + When "Alice" transfers the token to direct address "" + Examples: + | directAddress | + | DIRECT://00008f920927781ff8e20d09519dc84fe152d71f742beb6d393bf70459c2d6b014601917ce80 | \ No newline at end of file