From 20037147efdf94c33600a3914e0ad4003ca142ee Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:18:12 +0800 Subject: [PATCH 01/16] fix: coordinate Dioxide ISN reservations across processes with a durable journal --- acb-sdk/antchain-bridge-plugin-lib/pom.xml | 8 +- .../JdbcTransactionCoordinator.java | 219 +++++++++++++++ .../resources/db/dioxide_tx_coordinator.sql | 26 ++ .../transactions/CoordinatorProcessProbe.java | 26 ++ .../JdbcTransactionCoordinatorTest.java | 119 ++++++++ .../dioxide_tx_coordinator.py | 261 ++++++++++++++++++ .../dioxide-tx-coordinator/pyproject.toml | 13 + .../test_coordinator.py | 89 ++++++ 8 files changed, 760 insertions(+), 1 deletion(-) create mode 100644 acb-sdk/antchain-bridge-plugin-lib/src/main/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinator.java create mode 100644 acb-sdk/antchain-bridge-plugin-lib/src/main/resources/db/dioxide_tx_coordinator.sql create mode 100644 acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/CoordinatorProcessProbe.java create mode 100644 acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinatorTest.java create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/pyproject.toml create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/test_coordinator.py diff --git a/acb-sdk/antchain-bridge-plugin-lib/pom.xml b/acb-sdk/antchain-bridge-plugin-lib/pom.xml index ca33dcb0..fffabc0a 100644 --- a/acb-sdk/antchain-bridge-plugin-lib/pom.xml +++ b/acb-sdk/antchain-bridge-plugin-lib/pom.xml @@ -15,6 +15,12 @@ + + junitjunit4.13.2test + + + com.mysqlmysql-connector-j8.4.0test + org.pf4j pf4j @@ -61,4 +67,4 @@ https://maven.pkg.github.com/AntChainOpenLab/AntChainBridgePluginSDK - \ No newline at end of file + diff --git a/acb-sdk/antchain-bridge-plugin-lib/src/main/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinator.java b/acb-sdk/antchain-bridge-plugin-lib/src/main/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinator.java new file mode 100644 index 00000000..f00334ca --- /dev/null +++ b/acb-sdk/antchain-bridge-plugin-lib/src/main/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinator.java @@ -0,0 +1,219 @@ +package com.alipay.antchain.bridge.plugins.lib.transactions; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.io.InputStream; +import java.security.MessageDigest; +import java.sql.*; +import java.util.Properties; +import java.util.concurrent.Callable; + +/** + * Durable account-scoped allocation, shared across plugin class loaders and hosts. + * Schema is installed explicitly, never by a transaction-serving process. + * Signed bytes are committed BEFORE broadcast; uncertain submissions never allocate again. + */ +public final class JdbcTransactionCoordinator { + public static final long MAX_ISN = 0xffff_ffffL; + + public interface Transport { + String checkpoint() throws Exception; + long currentIsn(String account) throws Exception; + byte[] composeAndSign(long isn) throws Exception; + String broadcast(byte[] signed) throws Exception; + } + + public interface Connections { Connection open() throws SQLException; } + + private final Connections connections; + private final String network; + private final String checkpoint; + + public JdbcTransactionCoordinator(Connections connections, String network, String checkpoint) { + require(network, 96, "network"); + require(checkpoint, 128, "checkpoint"); + this.connections = connections; + this.network = network; + this.checkpoint = checkpoint; + } + + public static Properties readConfig(String filename) throws Exception { + if (filename == null || filename.trim().isEmpty()) { + throw new IllegalStateException("DIOXIDE_TX_COORDINATOR_CONFIG must be configured; unsafe allocation is disabled"); + } + Properties p = new Properties(); + try (InputStream in = Files.newInputStream(Paths.get(filename))) { p.load(in); } + return p; + } + + public static JdbcTransactionCoordinator fromProperties(Properties p) throws Exception { + final String url = required(p, "jdbcUrl"); + final Properties credentials = new Properties(); + credentials.setProperty("user", required(p, "user")); + credentials.setProperty("password", new String(Files.readAllBytes(Paths.get( + required(p, "passwordFile"))), StandardCharsets.UTF_8).trim()); + // DriverManager's caller class-loader filtering is unsuitable for PF4J plugin drivers. + final Driver driver = (Driver) Class.forName("com.mysql.cj.jdbc.Driver").newInstance(); + return new JdbcTransactionCoordinator(() -> { + Connection c = driver.connect(url, credentials); + if (c == null) { throw new SQLException("unsupported transaction coordinator JDBC URL"); } + return c; + }, required(p, "networkId"), required(p, "checkpointHash")); + } + + public static String required(Properties p, String name) { + String value = p.getProperty(name); + if (value == null || value.trim().isEmpty()) { throw new IllegalArgumentException("missing coordinator setting: " + name); } + return value.trim(); + } + + public static String sha256(byte[] input) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256").digest(input); + StringBuilder out = new StringBuilder(64); + for (byte b : bytes) { out.append(String.format("%02x", b & 255)); } + return out.toString(); + } catch (Exception e) { throw new IllegalStateException(e); } + } + + public static long nextIsn(long node, long persisted) { + if (node < 0 || node > MAX_ISN || persisted < 0 || persisted > MAX_ISN) { + throw new IllegalStateException("ISN exhausted or invalid; refusing wraparound"); + } + return Math.max(node, persisted); + } + + private static void require(String value, int limit, String field) { + if (value == null || value.isEmpty() || value.length() > limit || !value.matches("[\\x21-\\x7e]+")) { + throw new IllegalArgumentException("invalid coordinator " + field); + } + } + + private String lockName(String kind, String account) { + return sha256((kind + "|" + network + "|" + account).getBytes(StandardCharsets.UTF_8)); + } + + public static String normalizeAccount(String account) { + if (account != null && account.toLowerCase(java.util.Locale.ROOT).endsWith(":ed25519")) { + return account.substring(0, account.length() - 8).toLowerCase(java.util.Locale.ROOT); + } + return account; + } + + private static void acquire(Connection c, String name) throws SQLException { + try (PreparedStatement s = c.prepareStatement("SELECT GET_LOCK(?, 20)")) { + s.setString(1, name); + try (ResultSet r = s.executeQuery()) { + if (!r.next() || r.getInt(1) != 1 || r.wasNull()) { throw new SQLException("coordinator lock timeout"); } + } + } + } + + private static void release(Connection c, String name) { + try (PreparedStatement s = c.prepareStatement("SELECT RELEASE_LOCK(?)")) { + s.setString(1, name); s.executeQuery().close(); + } catch (SQLException ignored) { /* Closing the connection also releases its named locks. */ } + } + + /** Used only around the legacy shared SDP query mailbox, never normal confirmation polling. */ + public T withQueryLock(String contractAndAccount, Callable query) throws Exception { + String name = lockName("query", contractAndAccount); + try (Connection c = connections.open()) { + acquire(c, name); + try { return query.call(); } finally { release(c, name); } + } + } + + public String submit(String operationId, String account, byte[] payload, Transport transport) throws Exception { + account = normalizeAccount(account); + require(operationId, 191, "operationId"); + require(account, 160, "account"); + String name = lockName("submission", account); + String fingerprint = sha256(payload); + try (Connection c = connections.open()) { + acquire(c, name); + try { + if (!checkpoint.equals(transport.checkpoint())) { + throw new IllegalStateException("Dioxide network checkpoint changed; submission disabled"); + } + c.setAutoCommit(false); + byte[] signed; + try { + try (PreparedStatement s = c.prepareStatement( + "INSERT INTO bridge_tx_account(network_id,account,checkpoint_hash,next_isn) VALUES(?,?,?,0) " + + "ON DUPLICATE KEY UPDATE account=VALUES(account)")) { + s.setString(1, network); s.setString(2, account); s.setString(3, checkpoint); s.executeUpdate(); + } + long persisted; + try (PreparedStatement s = c.prepareStatement( + "SELECT checkpoint_hash,next_isn FROM bridge_tx_account WHERE network_id=? AND account=? FOR UPDATE")) { + s.setString(1, network); s.setString(2, account); + try (ResultSet r = s.executeQuery()) { + if (!r.next() || !checkpoint.equals(r.getString(1))) { throw new IllegalStateException("coordinator network mismatch"); } + persisted = r.getLong(2); + } + } + try (PreparedStatement s = c.prepareStatement( + "SELECT account,payload_hash,signed_tx,tx_hash FROM bridge_tx_submission WHERE network_id=? AND operation_id=? FOR UPDATE")) { + s.setString(1, network); s.setString(2, operationId); + try (ResultSet r = s.executeQuery()) { + if (r.next()) { + if (!account.equals(r.getString(1)) || !fingerprint.equals(r.getString(2))) { + throw new IllegalStateException("submission identity reused with different account or payload"); + } + signed = r.getBytes(3); + String hash = r.getString(4); + c.commit(); c.setAutoCommit(true); + if (hash != null && !hash.isEmpty()) { return hash; } + return broadcast(c, operationId, signed, transport); + } + } + } + long isn = nextIsn(transport.currentIsn(account), persisted); + signed = transport.composeAndSign(isn); + if (signed == null || signed.length == 0) { throw new IllegalStateException("empty signed transaction"); } + try (PreparedStatement s = c.prepareStatement( + "INSERT INTO bridge_tx_submission(network_id,operation_id,account,isn,payload_hash,signed_tx,state) VALUES(?,?,?,?,?,?,'SIGNED')")) { + s.setString(1, network); s.setString(2, operationId); s.setString(3, account); + s.setLong(4, isn); s.setString(5, fingerprint); s.setBytes(6, signed); s.executeUpdate(); + } + try (PreparedStatement s = c.prepareStatement( + "UPDATE bridge_tx_account SET next_isn=? WHERE network_id=? AND account=?")) { + s.setLong(1, isn + 1); s.setString(2, network); s.setString(3, account); s.executeUpdate(); + } + c.commit(); // Never move this below broadcast. + } catch (Exception e) { + try { if (!c.getAutoCommit()) { c.rollback(); } } catch (SQLException rollback) { e.addSuppressed(rollback); } + throw e; + } finally { c.setAutoCommit(true); } + return broadcast(c, operationId, signed, transport); + } finally { release(c, name); } + } + } + + private String broadcast(Connection c, String operationId, byte[] signed, Transport transport) throws Exception { + try { + String hash = transport.broadcast(signed); + if (hash == null || hash.trim().isEmpty()) { throw new IllegalStateException("broadcast returned no hash"); } + try (PreparedStatement s = c.prepareStatement( + "UPDATE bridge_tx_submission SET tx_hash=?,state='BROADCAST',last_error=NULL WHERE network_id=? AND operation_id=?")) { + s.setString(1, hash); s.setString(2, network); s.setString(3, operationId); s.executeUpdate(); + } + return hash; + } catch (Exception e) { + try (PreparedStatement s = c.prepareStatement( + "UPDATE bridge_tx_submission SET state='UNKNOWN',last_error=? WHERE network_id=? AND operation_id=? AND tx_hash IS NULL")) { + s.setString(1, e.getClass().getSimpleName()); s.setString(2, network); s.setString(3, operationId); s.executeUpdate(); + } catch (SQLException ignored) { /* SIGNED is also recoverable using the exact stored bytes. */ } + throw e; + } + } + + public void recordOutcome(String hash, boolean success) throws SQLException { + try (Connection c = connections.open(); PreparedStatement s = c.prepareStatement( + "UPDATE bridge_tx_submission SET state=? WHERE network_id=? AND tx_hash=?")) { + s.setString(1, success ? "FINALIZED" : "FAILED"); s.setString(2, network); s.setString(3, hash); s.executeUpdate(); + } + } +} diff --git a/acb-sdk/antchain-bridge-plugin-lib/src/main/resources/db/dioxide_tx_coordinator.sql b/acb-sdk/antchain-bridge-plugin-lib/src/main/resources/db/dioxide_tx_coordinator.sql new file mode 100644 index 00000000..7b580da8 --- /dev/null +++ b/acb-sdk/antchain-bridge-plugin-lib/src/main/resources/db/dioxide_tx_coordinator.sql @@ -0,0 +1,26 @@ +-- Install explicitly in the shared operational database. Do not reset after rollback. +CREATE TABLE IF NOT EXISTS bridge_tx_account ( + network_id VARCHAR(96) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + account VARCHAR(160) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + checkpoint_hash VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + next_isn BIGINT NOT NULL, + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (network_id, account) +) ENGINE=InnoDB; +CREATE TABLE IF NOT EXISTS bridge_tx_submission ( + network_id VARCHAR(96) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + operation_id VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + account VARCHAR(160) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + isn BIGINT NOT NULL, + payload_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + signed_tx MEDIUMBLOB NOT NULL, + tx_hash VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL, + state VARCHAR(16) NOT NULL, + last_error VARCHAR(128) NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (network_id, operation_id), + UNIQUE KEY uq_bridge_tx_isn (network_id, account, isn), + KEY ix_bridge_tx_hash (network_id, tx_hash), + KEY ix_bridge_tx_pending (network_id, state) +) ENGINE=InnoDB; diff --git a/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/CoordinatorProcessProbe.java b/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/CoordinatorProcessProbe.java new file mode 100644 index 00000000..01a2aaf8 --- /dev/null +++ b/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/CoordinatorProcessProbe.java @@ -0,0 +1,26 @@ +package com.alipay.antchain.bridge.plugins.lib.transactions; + +import java.nio.ByteBuffer; +import java.sql.DriverManager; + +/** Subprocess fixture only: no real node calls or credentials. */ +public class CoordinatorProcessProbe { + public static void main(String[] args) throws Exception { + Class.forName("com.mysql.cj.jdbc.Driver"); + JdbcTransactionCoordinator coordinator = new JdbcTransactionCoordinator( + () -> DriverManager.getConnection(args[0], "root", ""), args[1], "fixture"); + for (int i = 0; i < Integer.parseInt(args[3]); i++) { + String hash = coordinator.submit(args[2] + "-" + i, "account", + new byte[]{1, 2, 3}, new JdbcTransactionCoordinator.Transport() { + public String checkpoint() { return "fixture"; } + public long currentIsn(String account) { return 181; } + public byte[] composeAndSign(long isn) { return ByteBuffer.allocate(8).putLong(isn).array(); } + public String broadcast(byte[] signed) { + if (args.length > 4 && "crash".equals(args[4])) { Runtime.getRuntime().halt(17); } + return "tx-" + ByteBuffer.wrap(signed).getLong(); + } + }); + System.out.println(hash); + } + } +} diff --git a/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinatorTest.java b/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinatorTest.java new file mode 100644 index 00000000..f8ddf04a --- /dev/null +++ b/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinatorTest.java @@ -0,0 +1,119 @@ +package com.alipay.antchain.bridge.plugins.lib.transactions; + +import org.junit.*; +import java.sql.*; +import java.nio.ByteBuffer; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.Assert.*; + +public class JdbcTransactionCoordinatorTest { + private static final byte[] PAYLOAD = new byte[]{1, 2, 3}; + private String network; + private JdbcTransactionCoordinator coordinator; + private JdbcTransactionCoordinator.Connections connections; + + @Before public void setup() throws Exception { + String url = System.getProperty("isn.test.jdbc"); + Assume.assumeTrue("Use a disposable MySQL database with the coordinator schema installed", url != null); + Class.forName("com.mysql.cj.jdbc.Driver"); + connections = () -> DriverManager.getConnection(url, "root", ""); + network = "test-" + UUID.randomUUID(); + coordinator = new JdbcTransactionCoordinator(connections, network, "checkpoint"); + } + + private static class Node implements JdbcTransactionCoordinator.Transport { + final Set submitted = ConcurrentHashMap.newKeySet(); + final AtomicInteger composeCalls = new AtomicInteger(); + volatile boolean failSign; + volatile boolean loseResponse; + volatile String checkpoint = "checkpoint"; + long nodeIsn = 181; + public String checkpoint() { return checkpoint; } + public long currentIsn(String account) { return nodeIsn; } + public byte[] composeAndSign(long isn) throws Exception { + composeCalls.incrementAndGet(); + if (failSign) { throw new Exception("signing unavailable"); } + return ByteBuffer.allocate(8).putLong(isn).array(); + } + public String broadcast(byte[] bytes) throws Exception { + long isn = ByteBuffer.wrap(bytes).getLong(); + submitted.add(isn); // the node deduplicates identical signed bytes + if (loseResponse) { loseResponse = false; throw new java.net.SocketTimeoutException(); } + return "tx-" + isn; + } + } + + @Test public void concurrentClientsShareOneAccountWithoutLosingConcurrencyAcrossAccounts() throws Exception { + Node node = new Node(); + ExecutorService pool = Executors.newFixedThreadPool(16); + List> futures = new ArrayList<>(); + try { + for (int i = 0; i < 64; i++) { + final String id = "op-" + i; + futures.add(pool.submit(() -> new JdbcTransactionCoordinator(connections, network, "checkpoint") + .submit(id, "account", PAYLOAD, node))); + } + Set hashes = new HashSet<>(); + for (Future f : futures) { hashes.add(f.get(30, TimeUnit.SECONDS)); } + assertEquals(64, hashes.size()); + assertEquals(64, node.submitted.size()); + assertTrue(hashes.contains("tx-181")); assertTrue(hashes.contains("tx-244")); + assertEquals("tx-181", coordinator.submit("different-account", "other", PAYLOAD, new Node())); + } finally { pool.shutdownNow(); } + } + + @Test public void responseLossAndRestartReuseSignedBytes() throws Exception { + Node node = new Node(); node.loseResponse = true; + try { coordinator.submit("stable", "account", PAYLOAD, node); fail(); } + catch (java.net.SocketTimeoutException expected) { } + JdbcTransactionCoordinator restarted = new JdbcTransactionCoordinator(connections, network, "checkpoint"); + assertEquals("tx-181", restarted.submit("stable", "account", PAYLOAD, node)); + assertEquals("tx-181", restarted.submit("stable", "account", PAYLOAD, node)); + assertEquals(1, node.composeCalls.get()); assertEquals(1, node.submitted.size()); + assertEquals("tx-182", restarted.submit("new-intent-same-payload", "account", PAYLOAD, node)); + } + + @Test public void signingFailureRollsBackAndIdentityConflictsFailClosed() throws Exception { + Node node = new Node(); node.failSign = true; + try { coordinator.submit("stable", "account", PAYLOAD, node); fail(); } catch (Exception expected) { } + node.failSign = false; + assertEquals("tx-181", coordinator.submit("stable", "account", PAYLOAD, node)); + try { coordinator.submit("stable", "account", new byte[]{9}, node); fail(); } + catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("identity")); } + node.checkpoint = "another-chain"; + try { coordinator.submit("new", "account", PAYLOAD, node); fail(); } + catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("checkpoint")); } + } + + @Test public void queryMailboxLockCoversReadAfterWrite() throws Exception { + AtomicInteger mailbox = new AtomicInteger(); + ExecutorService pool = Executors.newFixedThreadPool(8); + List> futures = new ArrayList<>(); + try { + for (int i = 0; i < 16; i++) { + final int own = i; + futures.add(pool.submit(() -> coordinator.withQueryLock("sdp|account", () -> { + mailbox.set(own); Thread.sleep(5); return mailbox.get(); + }))); + } + for (int i = 0; i < futures.size(); i++) { assertEquals(i, (int) futures.get(i).get()); } + } finally { pool.shutdownNow(); } + } + + @Test public void databaseFailureDoesNotComposeOrBroadcast() throws Exception { + Node node = new Node(); + JdbcTransactionCoordinator unavailable = new JdbcTransactionCoordinator( + () -> { throw new SQLException("unavailable"); }, network, "checkpoint"); + try { unavailable.submit("op", "account", PAYLOAD, node); fail(); } catch (SQLException expected) { } + assertEquals(0, node.composeCalls.get()); assertTrue(node.submitted.isEmpty()); + } + + @Test public void unsigned32BoundaryDoesNotWrap() throws Exception { + Node node = new Node(); node.nodeIsn = 0xffff_ffffL; + assertEquals("tx-4294967295", coordinator.submit("last", "account", PAYLOAD, node)); + try { coordinator.submit("overflow", "account", PAYLOAD, node); fail(); } + catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("ISN")); } + } +} diff --git a/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py b/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py new file mode 100644 index 00000000..56943c89 --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py @@ -0,0 +1,261 @@ +"""Dioxide coordination protocol shared with JdbcTransactionCoordinator (no private keys stored).""" +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import time +import uuid +from contextlib import contextmanager +from pathlib import Path +from urllib.parse import urlsplit + +import pymysql + +MAX_ISN = 0xFFFFFFFF + + +def config_file(filename=None): + filename = filename or os.environ.get("DIOXIDE_TX_COORDINATOR_CONFIG") + if not filename: + raise RuntimeError("DIOXIDE_TX_COORDINATOR_CONFIG is required; unsafe allocation is disabled") + result = {} + for line in Path(filename).read_text().splitlines(): + if line.strip() and not line.lstrip().startswith(("#", "!")): + key, value = line.split("=", 1) + result[key.strip()] = value.strip() + return result + + +class Coordinator: + def __init__(self, config, connect=None): + self.config = config + self.network = config["networkId"] + self.checkpoint = config["checkpointHash"] + self._validate(self.network, 96) + self._validate(self.checkpoint, 128) + if connect is None: + uri = urlsplit(config["jdbcUrl"].removeprefix("jdbc:")) + password = Path(config["passwordFile"]).read_text().strip() + connect = lambda: pymysql.connect( + host=uri.hostname, port=uri.port or 3306, user=config["user"], password=password, + database=uri.path.lstrip("/"), charset="utf8mb4", autocommit=True, + connect_timeout=5, read_timeout=30, write_timeout=15, + ) + self.connect = connect + + @staticmethod + def _validate(value, maximum): + if not isinstance(value, str) or not value or len(value) > maximum or any(not 33 <= ord(c) <= 126 for c in value): + raise ValueError("invalid transaction coordinator identifier") + + @contextmanager + def lock(self, kind, account): + name = hashlib.sha256(f"{kind}|{self.network}|{account}".encode()).hexdigest() + connection = self.connect() + try: + with connection.cursor() as cursor: + cursor.execute("SELECT GET_LOCK(%s,20)", (name,)) + if cursor.fetchone()[0] != 1: + raise TimeoutError("coordinator lock timeout") + yield connection + finally: + try: + with connection.cursor() as cursor: + cursor.execute("SELECT RELEASE_LOCK(%s)", (name,)) + finally: + connection.close() + + def submit(self, operation_id, account, payload, transport): + if account.lower().endswith(":ed25519"): + account = account[:-8].lower() + self._validate(operation_id, 191) + self._validate(account, 160) + fingerprint = hashlib.sha256(payload).hexdigest() + with self.lock("submission", account) as connection: + if transport.checkpoint() != self.checkpoint: + raise RuntimeError("Dioxide network checkpoint changed; submission disabled") + connection.begin() + try: + with connection.cursor() as cursor: + cursor.execute( + "INSERT INTO bridge_tx_account(network_id,account,checkpoint_hash,next_isn) VALUES(%s,%s,%s,0) " + "ON DUPLICATE KEY UPDATE account=VALUES(account)", (self.network, account, self.checkpoint)) + cursor.execute("SELECT checkpoint_hash,next_isn FROM bridge_tx_account WHERE network_id=%s AND account=%s FOR UPDATE", + (self.network, account)) + checkpoint, persisted = cursor.fetchone() + if checkpoint != self.checkpoint: + raise RuntimeError("coordinator network mismatch") + cursor.execute( + "SELECT account,payload_hash,signed_tx,tx_hash FROM bridge_tx_submission WHERE network_id=%s AND operation_id=%s FOR UPDATE", + (self.network, operation_id)) + previous = cursor.fetchone() + if previous: + if previous[0] != account or previous[1] != fingerprint: + raise RuntimeError("submission identity reused with different account or payload") + signed, tx_hash = previous[2:] + else: + node_isn = transport.current_isn(account) + if not 0 <= node_isn <= MAX_ISN or not 0 <= persisted <= MAX_ISN: + raise RuntimeError("ISN exhausted or invalid; refusing wraparound") + isn = max(node_isn, persisted) + signed = transport.compose_and_sign(isn) + if not signed: + raise RuntimeError("empty signed transaction") + cursor.execute( + "INSERT INTO bridge_tx_submission(network_id,operation_id,account,isn,payload_hash,signed_tx,state) " + "VALUES(%s,%s,%s,%s,%s,%s,'SIGNED')", (self.network, operation_id, account, isn, fingerprint, signed)) + cursor.execute("UPDATE bridge_tx_account SET next_isn=%s WHERE network_id=%s AND account=%s", + (isn + 1, self.network, account)) + tx_hash = None + connection.commit() + except BaseException: + connection.rollback() + raise + if tx_hash: + return tx_hash + try: + tx_hash = transport.broadcast(signed) + if not tx_hash: + raise RuntimeError("broadcast returned no hash") + with connection.cursor() as cursor: + cursor.execute( + "UPDATE bridge_tx_submission SET tx_hash=%s,state='BROADCAST',last_error=NULL WHERE network_id=%s AND operation_id=%s", + (tx_hash, self.network, operation_id)) + return tx_hash + except Exception as error: + try: + with connection.cursor() as cursor: + cursor.execute( + "UPDATE bridge_tx_submission SET state='UNKNOWN',last_error=%s WHERE network_id=%s AND operation_id=%s AND tx_hash IS NULL", + (type(error).__name__, self.network, operation_id)) + except Exception: + pass # SIGNED also preserves the exact recovery bytes. + raise + + def record_outcome(self, tx_hash, success): + connection = self.connect() + try: + with connection.cursor() as cursor: + cursor.execute("UPDATE bridge_tx_submission SET state=%s WHERE network_id=%s AND tx_hash=%s", + ("FINALIZED" if success else "FAILED", self.network, tx_hash)) + finally: + connection.close() + + +class CoordinatedDioxClient: + """Wrap existing SDK reads while routing all supported writes through the shared journal.""" + def __init__(self, client, config=None): + self.client = client + self.coordinator = Coordinator(config or config_file()) + + def __getattr__(self, name): + # Do not silently forward a write that bypasses allocation. + if name.startswith(("send_", "deploy_", "mint_", "transfer", "create_")): + raise AttributeError(f"Use an explicitly coordinated operation instead of {name}") + return getattr(self.client, name) + + def send_transaction(self, user, function, args, tokens=None, isn=None, is_delegatee=False, + gas_price=None, gas_limit=None, is_sync=False, timeout=120000, + operation_id=None, delegatee=None, ttl=None): + if isn is not None: + raise ValueError("ISN is allocated by the shared coordinator, not the caller") + params = {"function": function, "args": args} + sender = str(delegatee if delegatee is not None else user.address) + if delegatee is not None or is_delegatee: + if ":" not in sender: + sender += ":dapp" + params["delegatee"] = sender + else: + params["sender"] = sender + for key, value in [("tokens", tokens), ("gasprice", gas_price), ("gaslimit", gas_limit), ("ttl", ttl)]: + if value is not None: + params[key] = value + operation_id = operation_id or "operation:" + str(uuid.uuid4()) + payload = json.dumps(params, separators=(",", ":"), ensure_ascii=False).encode() + client, coordinator = self.client, self.coordinator + + class Transport: + def checkpoint(self): + return client.make_request("dx.consensus_header", { + "query_type": 0, "height": int(coordinator.config["checkpointHeight"])})["Hash"] + + def current_isn(self, account): + return int(client.make_request("dx.isn", {"address": account})["ISN"]) + + def compose_and_sign(self, allocated): + unsigned = client.make_request("tx.compose", dict(params, isn=allocated)) + raw = base64.b64decode(unsigned["TxData"], validate=True) + if len(raw) < 12 or int.from_bytes(raw[8:12], "little") != allocated: + raise RuntimeError("node did not compose the reserved ISN") + return user.sign_diox_transaction(raw) + + def broadcast(self, signed): + return client.make_request("tx.send", {"txdata": base64.b64encode(signed).decode()})["Hash"] + + tx_hash = coordinator.submit(operation_id, sender, payload, Transport()) + if is_sync: + if not self.wait_for_transaction_confirmed(tx_hash, timeout): + raise TimeoutError(f"Dioxide synchronous submission timed out: {tx_hash}") + return tx_hash + + def wait_for_transaction_confirmed(self, tx_hash, timeout=120000): + deadline = time.monotonic() + timeout / 1000 + while time.monotonic() < deadline: + queue, seen, pending = [tx_hash], set(), False + while queue: + current = queue.pop().split(":")[0] + if current in seen: + continue + seen.add(current) + tx = self.client.make_request("dx.transaction", {"hash": current}) + if not isinstance(tx, dict): + pending = True + continue + if tx.get("ConfirmState") in {"TXN_ABORTED", "TXN_EXPIRED", "TXN_RELAY_INVALIDED"} or tx.get("State") in {"DUS_INVALID", "DUS_FORKED", "DUS_ARCHIVED_UNCLE"}: + self.coordinator.record_outcome(tx_hash, False) + raise RuntimeError(f"Dioxide transaction failed: {current} ({tx.get('ConfirmState')})") + if tx.get("ConfirmState") not in {"TXN_FINALIZED", "TXN_ARCHIVED"} and tx.get("State") not in {"DUS_FINALIZED", "DUS_ARCHIVED"}: + pending = True + def inspect(value): + invocation = value.get("Invocation") or {} + status = invocation.get("Status") + if status and status != "IVKRET_SUCCESS": + self.coordinator.record_outcome(tx_hash, False) + raise RuntimeError(f"Dioxide invocation failed: {current} ({status})") + queue.extend(invocation.get("Relays") or []) + for child in value.get("Relays") or []: + if isinstance(child, dict): + inspect(child) + inspect(tx) + if not pending: + self.coordinator.record_outcome(tx_hash, True) + return True + time.sleep(1) + return False + + def deploy_contracts(self, dapp_name, delegator, contracts, compile_time=None, operation_id=None): + args = {"code": [], "cargs": []} + for filename, constructor in contracts.items(): + args["code"].append(Path(filename).read_text()) + args["cargs"].append(json.dumps(constructor)) + if compile_time is not None: + args["time"] = compile_time + tx_hash = self.send_transaction(delegator, "core.delegation.deploy_contracts", args, + delegatee=dapp_name, is_sync=True, operation_id=operation_id) + self.client.wait_for_deploy(tx_hash) + return tx_hash + + def deploy_contract(self, dapp_name, delegator, file_path=None, source_code=None, construct_args=None, + compile_time=None, operation_id=None): + code = Path(file_path).read_text() if file_path else source_code + if code is None: + raise ValueError("contract source is required") + args = {"code": [code], "cargs": [json.dumps(construct_args)]} + if compile_time is not None: + args["time"] = compile_time + tx_hash = self.send_transaction(delegator, "core.delegation.deploy_contracts", args, + delegatee=dapp_name, is_sync=True, operation_id=operation_id) + self.client.wait_for_deploy(tx_hash) + return tx_hash diff --git a/acb-sdk/tools/dioxide-tx-coordinator/pyproject.toml b/acb-sdk/tools/dioxide-tx-coordinator/pyproject.toml new file mode 100644 index 00000000..1e3fce98 --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "dioxide-tx-coordinator" +version = "1.0.0" +description = "Shared durable Dioxide transaction allocation for AntChainBridge" +requires-python = ">=3.9" +dependencies = ["PyMySQL>=1.1,<2"] + +[tool.setuptools] +py-modules = ["dioxide_tx_coordinator"] diff --git a/acb-sdk/tools/dioxide-tx-coordinator/test_coordinator.py b/acb-sdk/tools/dioxide-tx-coordinator/test_coordinator.py new file mode 100644 index 00000000..d64db340 --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/test_coordinator.py @@ -0,0 +1,89 @@ +import concurrent.futures +import os +import subprocess +import unittest +import uuid + +import pymysql +from dioxide_tx_coordinator import Coordinator + + +def connection(): + return pymysql.connect(host="127.0.0.1", port=18236, user="root", password="", + database="isn_test", autocommit=True) + + +def coordinator(network): + return Coordinator({"networkId": network, "checkpointHash": "fixture"}, connection) + + +class Transport: + def checkpoint(self): + return "fixture" + + def current_isn(self, account): + return 181 + + def compose_and_sign(self, isn): + return isn.to_bytes(8, "big") + + def broadcast(self, signed): + return "tx-" + str(int.from_bytes(signed, "big")) + + +def python_process(network, prefix): + c = coordinator(network) + return [c.submit(f"{prefix}-{i}", "account:ed25519", bytes([1, 2, 3]), Transport()) for i in range(32)] + + +@unittest.skipUnless(os.environ.get("ISN_TEST_MYSQL") == "1", "requires disposable MySQL") +class CoordinatorTest(unittest.TestCase): + def setUp(self): + self.network = "python-" + str(uuid.uuid4()) + + def test_independent_intents_and_response_loss(self): + class Lost(Transport): + def broadcast(self, signed): + raise TimeoutError() + c = coordinator(self.network) + with self.assertRaises(TimeoutError): + c.submit("same", "account", b"payload", Lost()) + self.assertEqual("tx-181", coordinator(self.network).submit("same", "account", b"payload", Transport())) + self.assertEqual("tx-182", c.submit("other", "account", b"payload", Transport())) + with self.assertRaisesRegex(RuntimeError, "identity"): + c.submit("same", "account", b"changed", Transport()) + + def test_two_python_processes(self): + with concurrent.futures.ProcessPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(python_process, self.network, prefix) for prefix in ["a", "b"]] + values = [h for f in futures for h in f.result(timeout=60)] + self.assertEqual(64, len(set(values))) + + @unittest.skipUnless(os.environ.get("JAVA_PROBE_CLASSPATH"), "requires compiled Java fixture") + def test_two_java_and_two_python_processes_and_crash_recovery(self): + args = [os.environ["JAVA_PROBE_JAVA"], "-cp", os.environ["JAVA_PROBE_CLASSPATH"], + "com.alipay.antchain.bridge.plugins.lib.transactions.CoordinatorProcessProbe", + "jdbc:mysql://127.0.0.1:18236/isn_test?allowPublicKeyRetrieval=true&useSSL=false", + self.network] + processes = [subprocess.Popen(args + [prefix, "32"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + for prefix in ["j1", "j2"]] + with concurrent.futures.ProcessPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(python_process, self.network, prefix) for prefix in ["p1", "p2"]] + hashes = [h for f in futures for h in f.result(timeout=60)] + for process in processes: + stdout, stderr = process.communicate(timeout=60) + self.assertEqual(0, process.returncode, stderr) + hashes.extend(stdout.splitlines()) + self.assertEqual(128, len(set(hashes))) + crashed = subprocess.run(args + ["crash", "1", "crash"], capture_output=True, timeout=30) + self.assertEqual(17, crashed.returncode) + # The Python implementation resumes Java's committed signed bytes, not a new ISN. + recovered = coordinator(self.network).submit("crash-0", "account", bytes([1, 2, 3]), Transport()) + self.assertEqual("tx-309", recovered) + with connection() as c, c.cursor() as cursor: + cursor.execute("SELECT COUNT(*),COUNT(DISTINCT isn) FROM bridge_tx_submission WHERE network_id=%s", (self.network,)) + self.assertEqual((129, 129), cursor.fetchone()) + + +if __name__ == "__main__": + unittest.main() From 183ec049dec5432ee98037a2d1b80e12cd4efe04 Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:23:13 +0800 Subject: [PATCH 02/16] fix: reject Dioxide node counter rollback before allocating new transactions --- .../transactions/JdbcTransactionCoordinator.java | 14 ++++++++++---- .../main/resources/db/dioxide_tx_coordinator.sql | 1 + .../JdbcTransactionCoordinatorTest.java | 9 +++++++++ .../dioxide_tx_coordinator.py | 12 +++++++----- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/acb-sdk/antchain-bridge-plugin-lib/src/main/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinator.java b/acb-sdk/antchain-bridge-plugin-lib/src/main/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinator.java index f00334ca..c4d63086 100644 --- a/acb-sdk/antchain-bridge-plugin-lib/src/main/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinator.java +++ b/acb-sdk/antchain-bridge-plugin-lib/src/main/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinator.java @@ -146,12 +146,14 @@ public String submit(String operationId, String account, byte[] payload, Transpo s.setString(1, network); s.setString(2, account); s.setString(3, checkpoint); s.executeUpdate(); } long persisted; + long observed; try (PreparedStatement s = c.prepareStatement( - "SELECT checkpoint_hash,next_isn FROM bridge_tx_account WHERE network_id=? AND account=? FOR UPDATE")) { + "SELECT checkpoint_hash,next_isn,observed_isn FROM bridge_tx_account WHERE network_id=? AND account=? FOR UPDATE")) { s.setString(1, network); s.setString(2, account); try (ResultSet r = s.executeQuery()) { if (!r.next() || !checkpoint.equals(r.getString(1))) { throw new IllegalStateException("coordinator network mismatch"); } persisted = r.getLong(2); + observed = r.getLong(3); } } try (PreparedStatement s = c.prepareStatement( @@ -170,7 +172,11 @@ public String submit(String operationId, String account, byte[] payload, Transpo } } } - long isn = nextIsn(transport.currentIsn(account), persisted); + long nodeIsn = transport.currentIsn(account); + if (nodeIsn < observed) { + throw new IllegalStateException("node ISN regressed; reconcile network state before new submission"); + } + long isn = nextIsn(nodeIsn, persisted); signed = transport.composeAndSign(isn); if (signed == null || signed.length == 0) { throw new IllegalStateException("empty signed transaction"); } try (PreparedStatement s = c.prepareStatement( @@ -179,8 +185,8 @@ public String submit(String operationId, String account, byte[] payload, Transpo s.setLong(4, isn); s.setString(5, fingerprint); s.setBytes(6, signed); s.executeUpdate(); } try (PreparedStatement s = c.prepareStatement( - "UPDATE bridge_tx_account SET next_isn=? WHERE network_id=? AND account=?")) { - s.setLong(1, isn + 1); s.setString(2, network); s.setString(3, account); s.executeUpdate(); + "UPDATE bridge_tx_account SET next_isn=?,observed_isn=? WHERE network_id=? AND account=?")) { + s.setLong(1, isn + 1); s.setLong(2, nodeIsn); s.setString(3, network); s.setString(4, account); s.executeUpdate(); } c.commit(); // Never move this below broadcast. } catch (Exception e) { diff --git a/acb-sdk/antchain-bridge-plugin-lib/src/main/resources/db/dioxide_tx_coordinator.sql b/acb-sdk/antchain-bridge-plugin-lib/src/main/resources/db/dioxide_tx_coordinator.sql index 7b580da8..40681cd5 100644 --- a/acb-sdk/antchain-bridge-plugin-lib/src/main/resources/db/dioxide_tx_coordinator.sql +++ b/acb-sdk/antchain-bridge-plugin-lib/src/main/resources/db/dioxide_tx_coordinator.sql @@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS bridge_tx_account ( account VARCHAR(160) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, checkpoint_hash VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, next_isn BIGINT NOT NULL, + observed_isn BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (network_id, account) ) ENGINE=InnoDB; diff --git a/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinatorTest.java b/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinatorTest.java index f8ddf04a..7097ea62 100644 --- a/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinatorTest.java +++ b/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinatorTest.java @@ -116,4 +116,13 @@ public String broadcast(byte[] bytes) throws Exception { try { coordinator.submit("overflow", "account", PAYLOAD, node); fail(); } catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("ISN")); } } + + @Test public void regressedNodeCounterRequiresReconciliation() throws Exception { + Node node = new Node(); + coordinator.submit("first", "account", PAYLOAD, node); + node.nodeIsn = 180; + try { coordinator.submit("second", "account", PAYLOAD, node); fail(); } + catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("regressed")); } + assertEquals(1, node.composeCalls.get()); + } } diff --git a/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py b/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py index 56943c89..2a66bb11 100644 --- a/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py +++ b/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py @@ -17,7 +17,7 @@ def config_file(filename=None): - filename = filename or os.environ.get("DIOXIDE_TX_COORDINATOR_CONFIG") + filename = filename or os.environ.get("DIOXIDE_TX_COORDINATOR_CONFIG") or "/etc/antchain-bridge/dioxide-tx.properties" if not filename: raise RuntimeError("DIOXIDE_TX_COORDINATOR_CONFIG is required; unsafe allocation is disabled") result = {} @@ -82,9 +82,9 @@ def submit(self, operation_id, account, payload, transport): cursor.execute( "INSERT INTO bridge_tx_account(network_id,account,checkpoint_hash,next_isn) VALUES(%s,%s,%s,0) " "ON DUPLICATE KEY UPDATE account=VALUES(account)", (self.network, account, self.checkpoint)) - cursor.execute("SELECT checkpoint_hash,next_isn FROM bridge_tx_account WHERE network_id=%s AND account=%s FOR UPDATE", + cursor.execute("SELECT checkpoint_hash,next_isn,observed_isn FROM bridge_tx_account WHERE network_id=%s AND account=%s FOR UPDATE", (self.network, account)) - checkpoint, persisted = cursor.fetchone() + checkpoint, persisted, observed = cursor.fetchone() if checkpoint != self.checkpoint: raise RuntimeError("coordinator network mismatch") cursor.execute( @@ -97,6 +97,8 @@ def submit(self, operation_id, account, payload, transport): signed, tx_hash = previous[2:] else: node_isn = transport.current_isn(account) + if node_isn < observed: + raise RuntimeError("node ISN regressed; reconcile network state before new submission") if not 0 <= node_isn <= MAX_ISN or not 0 <= persisted <= MAX_ISN: raise RuntimeError("ISN exhausted or invalid; refusing wraparound") isn = max(node_isn, persisted) @@ -106,8 +108,8 @@ def submit(self, operation_id, account, payload, transport): cursor.execute( "INSERT INTO bridge_tx_submission(network_id,operation_id,account,isn,payload_hash,signed_tx,state) " "VALUES(%s,%s,%s,%s,%s,%s,'SIGNED')", (self.network, operation_id, account, isn, fingerprint, signed)) - cursor.execute("UPDATE bridge_tx_account SET next_isn=%s WHERE network_id=%s AND account=%s", - (isn + 1, self.network, account)) + cursor.execute("UPDATE bridge_tx_account SET next_isn=%s,observed_isn=%s WHERE network_id=%s AND account=%s", + (isn + 1, node_isn, self.network, account)) tx_hash = None connection.commit() except BaseException: From 5c60da7f7d886604ab3ecbe3ce96aad031aaf23e Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:23:14 +0800 Subject: [PATCH 03/16] fix: propagate stable UCP submission IDs and harden Dioxide query finality --- .../server/CrossChainServiceImpl.java | 2 +- .../src/main/proto/pluginserver.proto | 3 +- .../AMClientContractHeteroBlockchainImpl.java | 13 +- .../pluginserver/GRpcBBCServiceClient.java | 6 + .../r-core/src/main/proto/pluginserver.proto | 3 +- .../manager/bbc/SubmissionIdentityTest.java | 14 ++ .../plugins/spi/bbc/core/write/IAMWriter.java | 8 + .../pluginset/dioxide/offchain-plugin/pom.xml | 7 +- .../plugins/dioxide/DioxideBBCService.java | 10 +- .../plugins/dioxide/conf/DioxideConfig.java | 3 + .../plugins/dioxide/core/DioxideClient.java | 157 +++++++++++++----- .../dioxide/core/DioxideTransaction.java | 12 ++ .../core/DioxideClientFinalityTest.java | 23 +++ .../dioxide2/offchain-plugin/pom.xml | 7 +- .../plugins/dioxide2/DioxideBBCService.java | 10 +- .../plugins/dioxide2/conf/DioxideConfig.java | 3 + .../plugins/dioxide2/core/DioxideClient.java | 157 +++++++++++++----- .../dioxide2/core/DioxideTransaction.java | 12 ++ .../core/DioxideClientFinalityTest.java | 23 +++ 19 files changed, 375 insertions(+), 98 deletions(-) create mode 100644 acb-relayer/r-core/src/test/java/com/alipay/antchain/bridge/relayer/core/manager/bbc/SubmissionIdentityTest.java diff --git a/acb-pluginserver/ps-server/src/main/java/com/alipay/antchain/bridge/pluginserver/server/CrossChainServiceImpl.java b/acb-pluginserver/ps-server/src/main/java/com/alipay/antchain/bridge/pluginserver/server/CrossChainServiceImpl.java index 4ef4c0ff..005067ff 100644 --- a/acb-pluginserver/ps-server/src/main/java/com/alipay/antchain/bridge/pluginserver/server/CrossChainServiceImpl.java +++ b/acb-pluginserver/ps-server/src/main/java/com/alipay/antchain/bridge/pluginserver/server/CrossChainServiceImpl.java @@ -446,7 +446,7 @@ private Response handleSetPtcContract(IBBCService bbcService, SetPtcContractRequ private Response handleRelayAuthMessage(IBBCService bbcService, RelayAuthMessageRequest request, String product, String domain) { try { - CrossChainMessageReceipt ret = bbcService.relayAuthMessage(request.getRawMessage().toByteArray()); + CrossChainMessageReceipt ret = bbcService.relayAuthMessage(request.getRawMessage().toByteArray(), request.getSubmissionId()); return ResponseBuilder.buildBBCSuccessResp(CallBBCResponse.newBuilder() .setRelayAuthMessageResponse(RelayAuthMessageResponse.newBuilder() .setReceipt( diff --git a/acb-pluginserver/ps-service/src/main/proto/pluginserver.proto b/acb-pluginserver/ps-service/src/main/proto/pluginserver.proto index a7f48eda..05451add 100644 --- a/acb-pluginserver/ps-service/src/main/proto/pluginserver.proto +++ b/acb-pluginserver/ps-service/src/main/proto/pluginserver.proto @@ -160,6 +160,7 @@ message SetPtcContractRequest { message RelayAuthMessageRequest { bytes rawMessage = 1; + string submissionId = 2; } message SetAmContractRequest { @@ -429,4 +430,4 @@ message ReliableRetryResponse { message RelayMonitorOrderResponse { CrossChainMessageReceipt receipt = 1; -} \ No newline at end of file +} diff --git a/acb-relayer/r-core/src/main/java/com/alipay/antchain/bridge/relayer/core/manager/bbc/AMClientContractHeteroBlockchainImpl.java b/acb-relayer/r-core/src/main/java/com/alipay/antchain/bridge/relayer/core/manager/bbc/AMClientContractHeteroBlockchainImpl.java index 604a14ac..81c4f79b 100644 --- a/acb-relayer/r-core/src/main/java/com/alipay/antchain/bridge/relayer/core/manager/bbc/AMClientContractHeteroBlockchainImpl.java +++ b/acb-relayer/r-core/src/main/java/com/alipay/antchain/bridge/relayer/core/manager/bbc/AMClientContractHeteroBlockchainImpl.java @@ -24,7 +24,14 @@ public AMClientContractHeteroBlockchainImpl(IBBCServiceClient bbcServiceClient) @Override public SendResponseResult recvPkgFromRelayer(AuthMsgPackage pkg) { try { - CrossChainMessageReceipt receipt = bbcServiceClient.relayAuthMessage(pkg.extractProofs()); + String submissionId = ""; + if (pkg.getSdpMsgWrapper() != null && pkg.getSdpMsgWrapper().getAuthMsgWrapper() != null) { + String ucpId = pkg.getSdpMsgWrapper().getAuthMsgWrapper().getUcpId(); + if (ucpId != null && !ucpId.isEmpty()) { + submissionId = stableSubmissionId(ucpId, bbcServiceClient.getDomain()); + } + } + CrossChainMessageReceipt receipt = bbcServiceClient.relayAuthMessage(pkg.extractProofs(), submissionId); if (ObjectUtil.isNull(receipt)) { return new SendResponseResult( "", @@ -77,6 +84,10 @@ public void setProtocol(String protocolContract, String protocolType) { this.bbcServiceClient.setProtocol(protocolContract, protocolType); } + static String stableSubmissionId(String ucpId, String domain) { + return cn.hutool.crypto.digest.DigestUtil.sha256Hex("relay-am|" + ucpId + "|" + domain); + } + @Override public void setPtcContract(String ptcContractAddress) { this.bbcServiceClient.setPtcContract(ptcContractAddress); diff --git a/acb-relayer/r-core/src/main/java/com/alipay/antchain/bridge/relayer/core/types/pluginserver/GRpcBBCServiceClient.java b/acb-relayer/r-core/src/main/java/com/alipay/antchain/bridge/relayer/core/types/pluginserver/GRpcBBCServiceClient.java index 437ad921..14224bcf 100644 --- a/acb-relayer/r-core/src/main/java/com/alipay/antchain/bridge/relayer/core/types/pluginserver/GRpcBBCServiceClient.java +++ b/acb-relayer/r-core/src/main/java/com/alipay/antchain/bridge/relayer/core/types/pluginserver/GRpcBBCServiceClient.java @@ -350,6 +350,11 @@ public void setPtcContract(String ptcContractAddress) { @Override public CrossChainMessageReceipt relayAuthMessage(byte[] rawMessage) { + return relayAuthMessage(rawMessage, ""); + } + + @Override + public CrossChainMessageReceipt relayAuthMessage(byte[] rawMessage, String submissionId) { Response response = this.blockingStub.withDeadlineAfter(30, TimeUnit.SECONDS).bbcCall( CallBBCRequest.newBuilder() .setProduct(this.getProduct()) @@ -357,6 +362,7 @@ public CrossChainMessageReceipt relayAuthMessage(byte[] rawMessage) { .setRelayAuthMessageReq( RelayAuthMessageRequest.newBuilder() .setRawMessage(ByteString.copyFrom(rawMessage)) + .setSubmissionId(submissionId == null ? "" : submissionId) ).build() ); if (response.getCode() != 0) { diff --git a/acb-relayer/r-core/src/main/proto/pluginserver.proto b/acb-relayer/r-core/src/main/proto/pluginserver.proto index a7f48eda..05451add 100644 --- a/acb-relayer/r-core/src/main/proto/pluginserver.proto +++ b/acb-relayer/r-core/src/main/proto/pluginserver.proto @@ -160,6 +160,7 @@ message SetPtcContractRequest { message RelayAuthMessageRequest { bytes rawMessage = 1; + string submissionId = 2; } message SetAmContractRequest { @@ -429,4 +430,4 @@ message ReliableRetryResponse { message RelayMonitorOrderResponse { CrossChainMessageReceipt receipt = 1; -} \ No newline at end of file +} diff --git a/acb-relayer/r-core/src/test/java/com/alipay/antchain/bridge/relayer/core/manager/bbc/SubmissionIdentityTest.java b/acb-relayer/r-core/src/test/java/com/alipay/antchain/bridge/relayer/core/manager/bbc/SubmissionIdentityTest.java new file mode 100644 index 00000000..05818a69 --- /dev/null +++ b/acb-relayer/r-core/src/test/java/com/alipay/antchain/bridge/relayer/core/manager/bbc/SubmissionIdentityTest.java @@ -0,0 +1,14 @@ +package com.alipay.antchain.bridge.relayer.core.manager.bbc; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class SubmissionIdentityTest { + @Test public void identityUsesUcpAndTargetNotPayloadOrZeroMessageId() { + String first = AMClientContractHeteroBlockchainImpl.stableSubmissionId("ucp-a", "diox04"); + assertEquals(first, AMClientContractHeteroBlockchainImpl.stableSubmissionId("ucp-a", "diox04")); + assertNotEquals(first, AMClientContractHeteroBlockchainImpl.stableSubmissionId("ucp-b", "diox04")); + assertNotEquals(first, AMClientContractHeteroBlockchainImpl.stableSubmissionId("ucp-a", "diox11")); + assertEquals(64, first.length()); + } +} diff --git a/acb-sdk/antchain-bridge-spi/src/main/java/com/alipay/antchain/bridge/plugins/spi/bbc/core/write/IAMWriter.java b/acb-sdk/antchain-bridge-spi/src/main/java/com/alipay/antchain/bridge/plugins/spi/bbc/core/write/IAMWriter.java index 4d849012..6e0a22e9 100644 --- a/acb-sdk/antchain-bridge-spi/src/main/java/com/alipay/antchain/bridge/plugins/spi/bbc/core/write/IAMWriter.java +++ b/acb-sdk/antchain-bridge-spi/src/main/java/com/alipay/antchain/bridge/plugins/spi/bbc/core/write/IAMWriter.java @@ -46,4 +46,12 @@ public interface IAMWriter { * @return {@link CrossChainMessageReceipt} */ CrossChainMessageReceipt relayAuthMessage(byte[] rawMessage); + + /** + * Stable caller identity for durable submission. Legacy plugins retain their existing behavior. + * Implementations must not derive this identity from the (possibly identical) business payload. + */ + default CrossChainMessageReceipt relayAuthMessage(byte[] rawMessage, String submissionId) { + return relayAuthMessage(rawMessage); + } } diff --git a/acb-sdk/pluginset/dioxide/offchain-plugin/pom.xml b/acb-sdk/pluginset/dioxide/offchain-plugin/pom.xml index d580759b..b0853014 100644 --- a/acb-sdk/pluginset/dioxide/offchain-plugin/pom.xml +++ b/acb-sdk/pluginset/dioxide/offchain-plugin/pom.xml @@ -14,6 +14,11 @@ + + com.mysql + mysql-connector-j + 8.4.0 + com.alipay.antchain.bridge antchain-bridge-plugin-lib @@ -138,4 +143,4 @@ - \ No newline at end of file + diff --git a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/DioxideBBCService.java b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/DioxideBBCService.java index fba4401b..7ff921da 100644 --- a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/DioxideBBCService.java +++ b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/DioxideBBCService.java @@ -348,6 +348,11 @@ public CrossChainMessageReceipt relayMonitorOrder(String committeeId, String sig @Override public CrossChainMessageReceipt relayAuthMessage(byte[] rawMessage) { + return relayAuthMessage(rawMessage, ""); + } + + @Override + public CrossChainMessageReceipt relayAuthMessage(byte[] rawMessage, String submissionId) { // 1. check context if (ObjectUtil.isNull(this.bbcContext)) { throw new RuntimeException("empty bbc context"); @@ -356,10 +361,7 @@ public CrossChainMessageReceipt relayAuthMessage(byte[] rawMessage) { throw new RuntimeException("empty am contract in bbc context"); } - getBBCLogger().debug("relay AM {} to {} ", - HexUtil.encodeHexStr(rawMessage), this.config.getAmContractName()); - - return dioxideClient.relayMsgToAuthMsg(rawMessage); + return dioxideClient.relayMsgToAuthMsg(rawMessage, submissionId); } diff --git a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/conf/DioxideConfig.java b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/conf/DioxideConfig.java index 9a9a6073..84e91128 100644 --- a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/conf/DioxideConfig.java +++ b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/conf/DioxideConfig.java @@ -30,6 +30,9 @@ public static DioxideConfig fromJsonString(String jsonString) throws IOException @JSONField private String privateKey; + // Optional per-domain path; production normally shares the process-level config file. + private String txCoordinatorConfigFile; + // [address / Id] @JSONField private String amContractAddressDeployed; diff --git a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java index ef5567df..41ea87a7 100644 --- a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java +++ b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java @@ -26,6 +26,7 @@ import java.util.jar.JarFile; import java.util.stream.Collectors; import lombok.Getter; +import com.alipay.antchain.bridge.plugins.lib.transactions.JdbcTransactionCoordinator; import lombok.SneakyThrows; import org.apache.commons.collections.CollectionUtils; import org.slf4j.Logger; @@ -53,6 +54,24 @@ public class DioxideClient { private final ExecutorService executor; + private volatile JdbcTransactionCoordinator transactionCoordinator; + private long coordinatorCheckpointHeight; + + @SneakyThrows + private synchronized JdbcTransactionCoordinator coordinator() { + if (transactionCoordinator == null) { + String filename = config.getTxCoordinatorConfigFile(); + if (StrUtil.isEmpty(filename)) { + filename = System.getProperty("dioxide.tx.config", System.getenv("DIOXIDE_TX_COORDINATOR_CONFIG")); + if (StrUtil.isEmpty(filename)) { filename = "/etc/antchain-bridge/dioxide-tx.properties"; } + } + Properties p = JdbcTransactionCoordinator.readConfig(filename); + coordinatorCheckpointHeight = Long.parseLong(JdbcTransactionCoordinator.required(p, "checkpointHeight")); + transactionCoordinator = JdbcTransactionCoordinator.fromProperties(p); + } + return transactionCoordinator; + } + private static final int DEFAULT_TIMEOUT = 20 * 1000; private static final List PRE_CONTRACT_ORDER = List.of( @@ -288,6 +307,15 @@ private CrossChainMessageReceipt buildCrossChainMessageReceipt( receipt.setSuccessful(successful); receipt.setTxhash(dioxideTransaction == null ? "" : StrUtil.nullToEmpty(dioxideTransaction.getTxHash())); receipt.setErrorMsg(StrUtil.nullToEmpty(errorMsg)); + if (confirmed && dioxideTransaction != null) { + try { + coordinator().recordOutcome(dioxideTransaction.getTxHash(), successful); + } catch (Exception e) { + // The on-chain receipt remains authoritative; a journal outage cannot change its outcome. + getBbcLogger().warn("Could not update Dioxide submission outcome for {} ({})", + dioxideTransaction.getTxHash(), e.getClass().getSimpleName()); + } + } return receipt; } @@ -575,6 +603,16 @@ static boolean contractVersionMatches(String configuredContractCid, long current } public long querySdpSeq(String senderDomain, String senderID, String receiverDomain, String receiverID) { + try { + return coordinator().withQueryLock( + config.getDappName() + "." + config.getSdpContractName() + "|" + dioxideAccount.getAddressInString(), + () -> querySdpSeqLocked(senderDomain, senderID, receiverDomain, receiverID)); + } catch (Exception e) { + throw new RuntimeException("coordinated SDP sequence query failed", e); + } + } + + private long querySdpSeqLocked(String senderDomain, String senderID, String receiverDomain, String receiverID) { try { int[] senderDomainArray = toIntArray(senderDomain.getBytes(StandardCharsets.UTF_8)); int[] senderIDArray = toIntArray(HexUtil.decodeHex(senderID)); @@ -650,7 +688,10 @@ public void setProtocolToAuthMsg(String protocolCidInString, String protocolType } } - public CrossChainMessageReceipt relayMsgToAuthMsg(byte[] rawMessage) { + public CrossChainMessageReceipt relayMsgToAuthMsg(byte[] rawMessage, String submissionId) { + if (StrUtil.isEmpty(submissionId)) { + throw new IllegalArgumentException("Dioxide relay requires a stable submissionId; upgrade the Relayer and Plugin Server"); + } try { String txHash = sendTransaction( JSON.toJSONString(orderedMap( @@ -660,7 +701,7 @@ public CrossChainMessageReceipt relayMsgToAuthMsg(byte[] rawMessage) { "pkg", toIntArray(rawMessage) ), "gaslimit", 50000000)), - false + false, submissionId ); if (StrUtil.isEmpty(txHash)) { @@ -678,9 +719,7 @@ public CrossChainMessageReceipt relayMsgToAuthMsg(byte[] rawMessage) { } catch (Exception e) { throw new RuntimeException( - String.format("failed to relay AM %s to %s", - HexUtil.encodeHexStr(rawMessage), config.getAmContractName() - ), e + "failed to relay AM to " + config.getAmContractName(), e ); } } @@ -809,7 +848,7 @@ private boolean waitForDappDeployed(String txHash, int timeOut) { private List getAllRelayTransactions(DioxideTransaction tx, boolean detail) { List res = new ArrayList<>(); - if (!isTxConfirmedWithRelays(tx)) { + if (evaluateTxFinalityWithRelays(tx).state() != TxFinalityState.FINALIZED) { return res; } @@ -833,13 +872,13 @@ private List getAllRelayTransactions(DioxideTransaction tx, boolean } private boolean waitForTransactionConfirmed(String txHash, int timeOut) { - long start = System.currentTimeMillis(); - DioxideTransaction dioxideTransaction = getTransactionByHash(txHash); - - while (!isTxConfirmedWithRelays(dioxideTransaction)) { - if (System.currentTimeMillis() - start > timeOut) { - return false; + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(timeOut); + while (System.nanoTime() < deadline) { + TxFinalityResult result = evaluateTxFinalityWithRelays(getTransactionByHash(txHash)); + if (result.state() == TxFinalityState.FAILED) { + throw new IllegalStateException("Dioxide transaction failed: " + result.txHash() + " (" + result.confirmState() + ")"); } + if (result.state() == TxFinalityState.FINALIZED) { return true; } try { Thread.sleep(1000); } catch (InterruptedException e) { @@ -847,28 +886,7 @@ private boolean waitForTransactionConfirmed(String txHash, int timeOut) { return false; } } - return true; - } - - private boolean isTxConfirmedWithRelays(DioxideTransaction dioxideTransaction) { - Queue queue = new ArrayDeque<>(); - queue.add(dioxideTransaction.getTxHash()); - while (!queue.isEmpty()) { - String txHash = queue.poll(); - DioxideTransaction curTx = getTransactionByHash(txHash); - if (!isTxConfirmed(curTx)) { - return false; - } - if (curTx.getInvocation() != null && curTx.getInvocation().getRelays() != null) { - queue.addAll( - curTx.getInvocation().getRelays() - .stream() - .map(s -> s.split(":")[0]) - .toList() - ); - } - } - return true; + return false; } public boolean isTxConfirmed(DioxideTransaction tx) { @@ -951,10 +969,12 @@ static TxFinalityResult evaluateTxFinalityWithRelays( ); } - if (currentTx.getInvocation() == null || CollUtil.isEmpty(currentTx.getInvocation().getRelays())) { - continue; + List relayReferences = new ArrayList<>(); + String invocationFailure = collectInvocationReferences(currentTx, relayReferences); + if (invocationFailure != null) { + return new TxFinalityResult(TxFinalityState.FAILED, currentTx.getTxHash(), invocationFailure); } - for (String relayTxReference : currentTx.getInvocation().getRelays()) { + for (String relayTxReference : relayReferences) { String relayTxHash = normalizeRelayTxHash(relayTxReference); if (StrUtil.isEmpty(relayTxHash)) { if (pendingResult == null) { @@ -993,6 +1013,22 @@ static String normalizeRelayTxHash(String relayTxReference) { return separatorIndex >= 0 ? relayTxReference.substring(0, separatorIndex) : relayTxReference; } + private static String collectInvocationReferences(DioxideTransaction tx, List references) { + if (tx.getInvocation() != null) { + String status = tx.getInvocation().getStatus(); + if (StrUtil.isNotEmpty(status) && !INVOCATION_SUCCESS.equals(status)) { return status; } + if (tx.getInvocation().getRelays() != null) { references.addAll(tx.getInvocation().getRelays()); } + } + if (tx.getEmbeddedRelays() != null) { + for (DioxideTransaction embedded : tx.getEmbeddedRelays()) { + if (embedded == null) { continue; } + String error = collectInvocationReferences(embedded, references); + if (error != null) { return error; } + } + } + return null; + } + static RelayEventInspection inspectRelayEvents(JSONObject relayGroup, String relayGroupTxHash) { if (relayGroup == null) { return new RelayEventInspection(RelayEventState.PENDING, List.of(), ""); @@ -1084,10 +1120,45 @@ record RelayEventInspection( @SneakyThrows public String sendTransaction(String params, boolean sync) { -// getBbcLogger().info("params of compose: \n{}", JSON.toJSONString(JSON.parseObject(params), SerializerFeature.PrettyFormat)); - String unsigned_txn = composeTransaction(params); - String signed_txn = signTransaction(unsigned_txn); - return sendRawTransaction(signed_txn, sync); // return txHash + return sendTransaction(params, sync, "operation:" + UUID.randomUUID()); + } + + @SneakyThrows + public String sendTransaction(String params, boolean sync, String submissionId) { + JSONObject request = JSON.parseObject(params); + String account = request.getString(request.containsKey("delegatee") ? "delegatee" : "sender"); + JdbcTransactionCoordinator allocation = coordinator(); + String txHash = allocation.submit(submissionId, account, params.getBytes(StandardCharsets.UTF_8), + new JdbcTransactionCoordinator.Transport() { + public String checkpoint() { + return getConsensusHeaderByHeight(coordinatorCheckpointHeight).getString("Hash"); + } + public long currentIsn(String address) { + JSONObject ret = checkIfErrorResponse(makeRequest("dx.isn", JSON.toJSONString(orderedMap("address", address)))); + Long isn = ret.getLong("ISN"); + if (isn == null) { throw new IllegalStateException("node returned no ISN"); } + return isn; + } + public byte[] composeAndSign(long isn) { + request.put("isn", isn); + String unsigned = composeTransaction(JSON.toJSONString(request)); + byte[] encoded = Base64.getDecoder().decode(unsigned); + if (encoded.length < 12 || java.nio.ByteBuffer.wrap(encoded, 8, 4) + .order(java.nio.ByteOrder.LITTLE_ENDIAN).getInt() != (int) isn) { + throw new IllegalStateException("node did not compose the reserved ISN"); + } + getBbcLogger().info("Dioxide submission prepared: id={}, account={}, isn={}", submissionId, account, isn); + return Base64.getDecoder().decode(signTransaction(unsigned)); + } + public String broadcast(byte[] signed) { + return sendRawTransaction(Base64.getEncoder().encodeToString(signed), false); + } + }); + getBbcLogger().info("Dioxide submission accepted: id={}, account={}, hash={}", submissionId, account, txHash); + if (sync && !waitForTransactionConfirmed(txHash, DEFAULT_TIMEOUT)) { + throw new IllegalStateException("Dioxide synchronous submission timed out: " + txHash); + } + return txHash; } @SneakyThrows @@ -1113,7 +1184,9 @@ private String sendRawTransaction(String signed_txn, boolean sync) { JSONObject resp = checkIfErrorResponse(rawResp); String txHash = resp.getString("Hash"); if (sync) { - waitForTransactionConfirmed(txHash, DEFAULT_TIMEOUT); + if (!waitForTransactionConfirmed(txHash, DEFAULT_TIMEOUT)) { + throw new IllegalStateException("Dioxide synchronous submission timed out: " + txHash); + } } return txHash; } diff --git a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideTransaction.java b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideTransaction.java index 766ba932..c420487e 100644 --- a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideTransaction.java +++ b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideTransaction.java @@ -19,6 +19,18 @@ public class DioxideTransaction { @JSONField(name = "Hash") private String txHash; + @JSONField(name = "ISN") + private Long isn; + + @JSONField(name = "Signers") + private List signers; + + @JSONField(name = "Timestamp") + private Long timestamp; + + @JSONField(name = "Relays") + private List embeddedRelays; + @JSONField(name = "GasOffered") private Integer gasOffered; diff --git a/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java b/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java index ddcd744b..c7e5b260 100644 --- a/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java +++ b/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java @@ -11,6 +11,29 @@ public class DioxideClientFinalityTest { + @Test + public void testEmbeddedInvocationFailureAndChildFinality() { + DioxideTransaction root = DioxideTransaction.builder().txHash("root").confirmState("TXN_ARCHIVED") + .embeddedRelays(List.of(DioxideTransaction.builder() + .invocation(DioxideTransaction.Invocation.builder().status("IVKRET_EXCEPTION_THROWN").build()).build())).build(); + Assert.assertEquals(DioxideClient.TxFinalityState.FAILED, evaluate(root, Map.of()).state()); + root.setEmbeddedRelays(List.of(DioxideTransaction.builder() + .invocation(DioxideTransaction.Invocation.builder().status("IVKRET_SUCCESS").relays(List.of("child:0")).build()).build())); + DioxideTransaction child = DioxideTransaction.builder().txHash("child").confirmState("TXN_READY").build(); + Assert.assertEquals(DioxideClient.TxFinalityState.PENDING, evaluate(root, Map.of("child", child)).state()); + child.setConfirmState("TXN_ARCHIVED"); + Assert.assertEquals(DioxideClient.TxFinalityState.FINALIZED, evaluate(root, Map.of("child", child)).state()); + } + + @Test + public void testDiagnosticFieldsRetainUnsignedIsnAndSigner() { + DioxideTransaction tx = com.alibaba.fastjson.JSON.parseObject( + "{\"ISN\":4294967295,\"Signers\":[\"account:ed25519\"],\"Timestamp\":1788415666329}", + DioxideTransaction.class); + Assert.assertEquals(Long.valueOf(4294967295L), tx.getIsn()); + Assert.assertEquals(List.of("account:ed25519"), tx.getSigners()); + } + @Test public void testContractVersionMatches() { Assert.assertTrue(DioxideClient.contractVersionMatches("1043692781569", 1043692781569L)); diff --git a/acb-sdk/pluginset/dioxide2/offchain-plugin/pom.xml b/acb-sdk/pluginset/dioxide2/offchain-plugin/pom.xml index 766797e4..f1bdbfc5 100644 --- a/acb-sdk/pluginset/dioxide2/offchain-plugin/pom.xml +++ b/acb-sdk/pluginset/dioxide2/offchain-plugin/pom.xml @@ -14,6 +14,11 @@ + + com.mysql + mysql-connector-j + 8.4.0 + com.alipay.antchain.bridge antchain-bridge-plugin-lib @@ -138,4 +143,4 @@ - \ No newline at end of file + diff --git a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/DioxideBBCService.java b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/DioxideBBCService.java index 4e636e70..58f015a5 100644 --- a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/DioxideBBCService.java +++ b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/DioxideBBCService.java @@ -334,6 +334,11 @@ public void setLocalDomain(String domain) { @Override public CrossChainMessageReceipt relayAuthMessage(byte[] rawMessage) { + return relayAuthMessage(rawMessage, ""); + } + + @Override + public CrossChainMessageReceipt relayAuthMessage(byte[] rawMessage, String submissionId) { // 1. check context if (ObjectUtil.isNull(this.bbcContext)) { throw new RuntimeException("empty bbc context"); @@ -342,10 +347,7 @@ public CrossChainMessageReceipt relayAuthMessage(byte[] rawMessage) { throw new RuntimeException("empty am contract in bbc context"); } - getBBCLogger().debug("relay AM {} to {} ", - HexUtil.encodeHexStr(rawMessage), this.config.getAmContractName()); - - return dioxideClient.relayMsgToAuthMsg(rawMessage); + return dioxideClient.relayMsgToAuthMsg(rawMessage, submissionId); } diff --git a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/conf/DioxideConfig.java b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/conf/DioxideConfig.java index a20e2293..1a0fa463 100644 --- a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/conf/DioxideConfig.java +++ b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/conf/DioxideConfig.java @@ -30,6 +30,9 @@ public static DioxideConfig fromJsonString(String jsonString) throws IOException @JSONField private String privateKey; + // Optional per-domain path; production normally shares the process-level config file. + private String txCoordinatorConfigFile; + // [address / Id] @JSONField private String amContractAddressDeployed; diff --git a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java index c80926cf..2d80cbbf 100644 --- a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java +++ b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java @@ -26,6 +26,7 @@ import java.util.jar.JarFile; import java.util.stream.Collectors; import lombok.Getter; +import com.alipay.antchain.bridge.plugins.lib.transactions.JdbcTransactionCoordinator; import lombok.SneakyThrows; import org.apache.commons.collections.CollectionUtils; import org.slf4j.Logger; @@ -53,6 +54,24 @@ public class DioxideClient { private final ExecutorService executor; + private volatile JdbcTransactionCoordinator transactionCoordinator; + private long coordinatorCheckpointHeight; + + @SneakyThrows + private synchronized JdbcTransactionCoordinator coordinator() { + if (transactionCoordinator == null) { + String filename = config.getTxCoordinatorConfigFile(); + if (StrUtil.isEmpty(filename)) { + filename = System.getProperty("dioxide.tx.config", System.getenv("DIOXIDE_TX_COORDINATOR_CONFIG")); + if (StrUtil.isEmpty(filename)) { filename = "/etc/antchain-bridge/dioxide-tx.properties"; } + } + Properties p = JdbcTransactionCoordinator.readConfig(filename); + coordinatorCheckpointHeight = Long.parseLong(JdbcTransactionCoordinator.required(p, "checkpointHeight")); + transactionCoordinator = JdbcTransactionCoordinator.fromProperties(p); + } + return transactionCoordinator; + } + private static final int DEFAULT_TIMEOUT = 20 * 1000; private static final List PRE_CONTRACT_ORDER = List.of( @@ -291,6 +310,15 @@ private CrossChainMessageReceipt buildCrossChainMessageReceipt( receipt.setSuccessful(successful); receipt.setTxhash(dioxideTransaction == null ? "" : StrUtil.nullToEmpty(dioxideTransaction.getTxHash())); receipt.setErrorMsg(StrUtil.nullToEmpty(errorMsg)); + if (confirmed && dioxideTransaction != null) { + try { + coordinator().recordOutcome(dioxideTransaction.getTxHash(), successful); + } catch (Exception e) { + // The on-chain receipt remains authoritative; a journal outage cannot change its outcome. + getBbcLogger().warn("Could not update Dioxide submission outcome for {} ({})", + dioxideTransaction.getTxHash(), e.getClass().getSimpleName()); + } + } return receipt; } @@ -610,6 +638,16 @@ static boolean contractVersionMatches(String configuredContractCid, long current } public long querySdpSeq(String senderDomain, String senderID, String receiverDomain, String receiverID) { + try { + return coordinator().withQueryLock( + config.getDappName() + "." + config.getSdpContractName() + "|" + dioxideAccount.getAddressInString(), + () -> querySdpSeqLocked(senderDomain, senderID, receiverDomain, receiverID)); + } catch (Exception e) { + throw new RuntimeException("coordinated SDP sequence query failed", e); + } + } + + private long querySdpSeqLocked(String senderDomain, String senderID, String receiverDomain, String receiverID) { try { int[] senderDomainArray = toIntArray(senderDomain.getBytes(StandardCharsets.UTF_8)); int[] senderIDArray = toIntArray(HexUtil.decodeHex(senderID)); @@ -685,7 +723,10 @@ public void setProtocolToAuthMsg(String protocolCidInString, String protocolType } } - public CrossChainMessageReceipt relayMsgToAuthMsg(byte[] rawMessage) { + public CrossChainMessageReceipt relayMsgToAuthMsg(byte[] rawMessage, String submissionId) { + if (StrUtil.isEmpty(submissionId)) { + throw new IllegalArgumentException("Dioxide relay requires a stable submissionId; upgrade the Relayer and Plugin Server"); + } try { String txHash = sendTransaction( JSON.toJSONString(orderedMap( @@ -695,7 +736,7 @@ public CrossChainMessageReceipt relayMsgToAuthMsg(byte[] rawMessage) { "pkg", toIntArray(rawMessage) ), "gaslimit", 50000000)), - false + false, submissionId ); if (StrUtil.isEmpty(txHash)) { @@ -713,9 +754,7 @@ public CrossChainMessageReceipt relayMsgToAuthMsg(byte[] rawMessage) { } catch (Exception e) { throw new RuntimeException( - String.format("failed to relay AM %s to %s", - HexUtil.encodeHexStr(rawMessage), config.getAmContractName() - ), e + "failed to relay AM to " + config.getAmContractName(), e ); } } @@ -908,7 +947,7 @@ private boolean waitForDappDeployed(String txHash, int timeOut) { private List getAllRelayTransactions(DioxideTransaction tx, boolean detail) { List res = new ArrayList<>(); - if (!isTxConfirmedWithRelays(tx)) { + if (evaluateTxFinalityWithRelays(tx).state() != TxFinalityState.FINALIZED) { return res; } @@ -932,13 +971,13 @@ private List getAllRelayTransactions(DioxideTransaction tx, boolean } private boolean waitForTransactionConfirmed(String txHash, int timeOut) { - long start = System.currentTimeMillis(); - DioxideTransaction dioxideTransaction = getTransactionByHash(txHash); - - while (!isTxConfirmedWithRelays(dioxideTransaction)) { - if (System.currentTimeMillis() - start > timeOut) { - return false; + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(timeOut); + while (System.nanoTime() < deadline) { + TxFinalityResult result = evaluateTxFinalityWithRelays(getTransactionByHash(txHash)); + if (result.state() == TxFinalityState.FAILED) { + throw new IllegalStateException("Dioxide transaction failed: " + result.txHash() + " (" + result.confirmState() + ")"); } + if (result.state() == TxFinalityState.FINALIZED) { return true; } try { Thread.sleep(1000); } catch (InterruptedException e) { @@ -946,28 +985,7 @@ private boolean waitForTransactionConfirmed(String txHash, int timeOut) { return false; } } - return true; - } - - private boolean isTxConfirmedWithRelays(DioxideTransaction dioxideTransaction) { - Queue queue = new ArrayDeque<>(); - queue.add(dioxideTransaction.getTxHash()); - while (!queue.isEmpty()) { - String txHash = queue.poll(); - DioxideTransaction curTx = getTransactionByHash(txHash); - if (!isTxConfirmed(curTx)) { - return false; - } - if (curTx.getInvocation() != null && curTx.getInvocation().getRelays() != null) { - queue.addAll( - curTx.getInvocation().getRelays() - .stream() - .map(s -> s.split(":")[0]) - .toList() - ); - } - } - return true; + return false; } public boolean isTxConfirmed(DioxideTransaction tx) { @@ -1050,10 +1068,12 @@ static TxFinalityResult evaluateTxFinalityWithRelays( ); } - if (currentTx.getInvocation() == null || CollUtil.isEmpty(currentTx.getInvocation().getRelays())) { - continue; + List relayReferences = new ArrayList<>(); + String invocationFailure = collectInvocationReferences(currentTx, relayReferences); + if (invocationFailure != null) { + return new TxFinalityResult(TxFinalityState.FAILED, currentTx.getTxHash(), invocationFailure); } - for (String relayTxReference : currentTx.getInvocation().getRelays()) { + for (String relayTxReference : relayReferences) { String relayTxHash = normalizeRelayTxHash(relayTxReference); if (StrUtil.isEmpty(relayTxHash)) { if (pendingResult == null) { @@ -1092,6 +1112,22 @@ static String normalizeRelayTxHash(String relayTxReference) { return separatorIndex >= 0 ? relayTxReference.substring(0, separatorIndex) : relayTxReference; } + private static String collectInvocationReferences(DioxideTransaction tx, List references) { + if (tx.getInvocation() != null) { + String status = tx.getInvocation().getStatus(); + if (StrUtil.isNotEmpty(status) && !INVOCATION_SUCCESS.equals(status)) { return status; } + if (tx.getInvocation().getRelays() != null) { references.addAll(tx.getInvocation().getRelays()); } + } + if (tx.getEmbeddedRelays() != null) { + for (DioxideTransaction embedded : tx.getEmbeddedRelays()) { + if (embedded == null) { continue; } + String error = collectInvocationReferences(embedded, references); + if (error != null) { return error; } + } + } + return null; + } + static RelayEventInspection inspectRelayEvents(JSONObject relayGroup, String relayGroupTxHash) { if (relayGroup == null) { return new RelayEventInspection(RelayEventState.PENDING, List.of(), ""); @@ -1183,10 +1219,45 @@ record RelayEventInspection( @SneakyThrows public String sendTransaction(String params, boolean sync) { -// getBbcLogger().info("params of compose: \n{}", JSON.toJSONString(JSON.parseObject(params), SerializerFeature.PrettyFormat)); - String unsigned_txn = composeTransaction(params); - String signed_txn = signTransaction(unsigned_txn); - return sendRawTransaction(signed_txn, sync); // return txHash + return sendTransaction(params, sync, "operation:" + UUID.randomUUID()); + } + + @SneakyThrows + public String sendTransaction(String params, boolean sync, String submissionId) { + JSONObject request = JSON.parseObject(params); + String account = request.getString(request.containsKey("delegatee") ? "delegatee" : "sender"); + JdbcTransactionCoordinator allocation = coordinator(); + String txHash = allocation.submit(submissionId, account, params.getBytes(StandardCharsets.UTF_8), + new JdbcTransactionCoordinator.Transport() { + public String checkpoint() { + return getConsensusHeaderByHeight(coordinatorCheckpointHeight).getString("Hash"); + } + public long currentIsn(String address) { + JSONObject ret = checkIfErrorResponse(makeRequest("dx.isn", JSON.toJSONString(orderedMap("address", address)))); + Long isn = ret.getLong("ISN"); + if (isn == null) { throw new IllegalStateException("node returned no ISN"); } + return isn; + } + public byte[] composeAndSign(long isn) { + request.put("isn", isn); + String unsigned = composeTransaction(JSON.toJSONString(request)); + byte[] encoded = Base64.getDecoder().decode(unsigned); + if (encoded.length < 12 || java.nio.ByteBuffer.wrap(encoded, 8, 4) + .order(java.nio.ByteOrder.LITTLE_ENDIAN).getInt() != (int) isn) { + throw new IllegalStateException("node did not compose the reserved ISN"); + } + getBbcLogger().info("Dioxide submission prepared: id={}, account={}, isn={}", submissionId, account, isn); + return Base64.getDecoder().decode(signTransaction(unsigned)); + } + public String broadcast(byte[] signed) { + return sendRawTransaction(Base64.getEncoder().encodeToString(signed), false); + } + }); + getBbcLogger().info("Dioxide submission accepted: id={}, account={}, hash={}", submissionId, account, txHash); + if (sync && !waitForTransactionConfirmed(txHash, DEFAULT_TIMEOUT)) { + throw new IllegalStateException("Dioxide synchronous submission timed out: " + txHash); + } + return txHash; } @SneakyThrows @@ -1212,7 +1283,9 @@ private String sendRawTransaction(String signed_txn, boolean sync) { JSONObject resp = checkIfErrorResponse(rawResp); String txHash = resp.getString("Hash"); if (sync) { - waitForTransactionConfirmed(txHash, DEFAULT_TIMEOUT); + if (!waitForTransactionConfirmed(txHash, DEFAULT_TIMEOUT)) { + throw new IllegalStateException("Dioxide synchronous submission timed out: " + txHash); + } } return txHash; } diff --git a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideTransaction.java b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideTransaction.java index dc0e1aed..8629ab60 100644 --- a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideTransaction.java +++ b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideTransaction.java @@ -19,6 +19,18 @@ public class DioxideTransaction { @JSONField(name = "Hash") private String txHash; + @JSONField(name = "ISN") + private Long isn; + + @JSONField(name = "Signers") + private List signers; + + @JSONField(name = "Timestamp") + private Long timestamp; + + @JSONField(name = "Relays") + private List embeddedRelays; + @JSONField(name = "GasOffered") private Integer gasOffered; diff --git a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java index 00376a36..68c79307 100644 --- a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java +++ b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java @@ -11,6 +11,29 @@ public class DioxideClientFinalityTest { + @Test + public void testEmbeddedInvocationFailureAndChildFinality() { + DioxideTransaction root = DioxideTransaction.builder().txHash("root").confirmState("TXN_ARCHIVED") + .embeddedRelays(List.of(DioxideTransaction.builder() + .invocation(DioxideTransaction.Invocation.builder().status("IVKRET_EXCEPTION_THROWN").build()).build())).build(); + Assert.assertEquals(DioxideClient.TxFinalityState.FAILED, evaluate(root, Map.of()).state()); + root.setEmbeddedRelays(List.of(DioxideTransaction.builder() + .invocation(DioxideTransaction.Invocation.builder().status("IVKRET_SUCCESS").relays(List.of("child:0")).build()).build())); + DioxideTransaction child = DioxideTransaction.builder().txHash("child").confirmState("TXN_READY").build(); + Assert.assertEquals(DioxideClient.TxFinalityState.PENDING, evaluate(root, Map.of("child", child)).state()); + child.setConfirmState("TXN_ARCHIVED"); + Assert.assertEquals(DioxideClient.TxFinalityState.FINALIZED, evaluate(root, Map.of("child", child)).state()); + } + + @Test + public void testDiagnosticFieldsRetainUnsignedIsnAndSigner() { + DioxideTransaction tx = com.alibaba.fastjson.JSON.parseObject( + "{\"ISN\":4294967295,\"Signers\":[\"account:ed25519\"],\"Timestamp\":1788415666329}", + DioxideTransaction.class); + Assert.assertEquals(Long.valueOf(4294967295L), tx.getIsn()); + Assert.assertEquals(List.of("account:ed25519"), tx.getSigners()); + } + @Test public void testContractVersionMatches() { Assert.assertTrue(DioxideClient.contractVersionMatches("1043692781569", 1043692781569L)); From 007c7fead292ae1452db76becda199ecb9a9f631 Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:24:05 +0800 Subject: [PATCH 04/16] docs: add coordinated Dioxide maintenance tools and rollout runbook --- .../tools/dioxide-tx-coordinator/.gitignore | 4 + .../tools/dioxide-tx-coordinator/README.md | 89 ++++ .../dioxide-tx.properties.example | 8 + .../install_server_store.py | 76 +++ .../scripts/dioxide_send_message.py | 105 +++++ .../scripts/redeploy_dioxide_app_contract.py | 158 +++++++ .../redeploy_dioxide_system_contracts.py | 431 ++++++++++++++++++ 7 files changed, 871 insertions(+) create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/.gitignore create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/README.md create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/dioxide-tx.properties.example create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/install_server_store.py create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/scripts/dioxide_send_message.py create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/scripts/redeploy_dioxide_app_contract.py create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/scripts/redeploy_dioxide_system_contracts.py diff --git a/acb-sdk/tools/dioxide-tx-coordinator/.gitignore b/acb-sdk/tools/dioxide-tx-coordinator/.gitignore new file mode 100644 index 00000000..f47a5b3c --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.egg-info/ +build/ +dist/ diff --git a/acb-sdk/tools/dioxide-tx-coordinator/README.md b/acb-sdk/tools/dioxide-tx-coordinator/README.md new file mode 100644 index 00000000..eb3ee8b4 --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/README.md @@ -0,0 +1,89 @@ +# Dioxide 并发提交与 ISN 运维说明 + +## 原因和保证 + +Dioxide `tx.compose` 默认读取当前 ISN,并不预留。消息会话锁与插件对象锁不能保护不同业务域、 +不同类加载器或不同主机上共用的链账户。生产 diox04/diox11 共用账户,因此两个插件和 Python +发送工具必须连接同一个协调数据库。 + +协调键是 `networkId + effective account`:普通调用使用 sender,委托调用使用 delegatee; +Ed25519 地址带类型后缀与裸地址使用同一个协调键。不能用域名、插件类型或 RPC URL 分别计数。 +每次新分配核对已归档区块 checkpoint,以及节点 ISN 已观察值,节点回退时停止新分配。 + +数据库行锁与命名锁保护预留、签名保存和广播;等待链上确认不占账户锁。签名数据在广播前提交, +因此断电、RPC 响应丢失、JVM 重启都不会使同一 operationId 换号重签。数据库不可用时禁止降级。 +已签名记录从不删除或回退;uint32 最大值之后必须停止,由运维核查,不能回绕。 + +Relayer 传入的 submissionId 为 UCP + 目标域 + relay-am 的稳定摘要,与可能全零的 messageId +及业务正文无关。同一正文的两条独立 UCP 不去重;同一 submissionId 的参数变更直接拒绝。 +查询 SDP 序号的共享 mailbox 从查询提交至完整确认、读回结果都受独立分布式锁保护。 + +## 安装与配置 + +1. 在共享运维数据库安装 `antchain-bridge-plugin-lib/src/main/resources/db/dioxide_tx_coordinator.sql`。 + 新增 bridge_tx_account、bridge_tx_submission,不改 Relayer 核心表。运行账号仅需这两表的 + SELECT/INSERT/UPDATE;DDL 由运维执行。 +2. 用已核实的归档区块设置 networkId、checkpointHeight、checkpointHash。所有同网络实例必须一致; + 恢复旧快照或更换网络后禁止直接更换 namespace 绕过旧未决记录,应先对账。 +3. 参照 dioxide-tx.properties.example 创建 root-only 配置、密码文件,均为 0600。 + 默认路径 /etc/antchain-bridge/dioxide-tx.properties;可用 DIOXIDE_TX_COORDINATOR_CONFIG 或 + Java 系统属性 dioxide.tx.config 覆盖。域配置 txCoordinatorConfigFile 具有最高优先级。 +4. 同时部署新 SPI、Plugin Server、Relayer、dioxide 和 dioxide2 插件。旧 Relayer 不传提交标识, + 新 Dioxide 插件会拒绝不带标识的跨链提交,而不是降级到不安全路径。 +5. 在调用脚本使用的 Python 虚拟环境中执行 `pip install ./acb-sdk/tools/dioxide-tx-coordinator`。 + Runner 使用 crosschain 配套 PR 的 adapter。旧 Python SDK 直接调用不受本组件保护, + 共享签名账户不能再通过未接入协调的脚本发交易;不能给其它进程配置独立的本地计数器。 + +本组件不保存私钥。signed_tx 是已签名交易,虽然不能用于重签,也不能公开或写入操作日志。 +发布包、Git、PR 和诊断清单均不包含真实数据库凭据、私钥或完整 signed_tx。 + +## 发送、部署工具与恢复 + +维护工具位于 scripts/。发送操作必须提供独立 operation-id;同一操作恢复时复用该 ID: + +```bash +python scripts/dioxide_send_message.py --operation-id test-20260904-001 \ + --config /root/workspace2026/dioxide2.json --dapp kt3_20 \ + --app-contract AppContractV2 --target-domain TARGET_DOMAIN \ + --target-identity TARGET_32_BYTE_HEX --message 'controlled test' +``` + +以上是示意参数,不应直接对真实业务合约执行。部署脚本也要求 operation-id,并为部署和各个绑定 +阶段使用独立标识;本次 ISN 修复不执行任何合约重部署。 + +- SIGNED:签名已经持久化,可能尚未广播,也可能广播后进程退出。 +- UNKNOWN:广播结果未知;只能查询或重新广播原始签名字节,不得换 ISN。 +- BROADCAST:已拿到哈希,原提交标识直接返回同一哈希。 +- FINALIZED/FAILED:来自完整链上执行结果,不能将失败冒充成功。 + +运维查询只选择 operation_id/account/isn/tx_hash/state/last_error,不输出 signed_tx。 +CLI 或 Relayer 使用原提交标识恢复时,参数必须与首次一致;业务发起方不得换标识掩盖超时。 +若原签名交易已过期或链已回滚,保留记录并人工对账,不自动构造替代交易。 + +## 测试 + +使用独立 MySQL(本地验证容器 crosschain-isn-test-mysql,127.0.0.1:18236),不要使用生产库运行 +测试 fixture。安装 SQL 后,设置 Maven 属性 isn.test.jdbc 运行 JdbcTransactionCoordinatorTest。 +Python 测试以 ISN_TEST_MYSQL=1 开启;JAVA_PROBE_JAVA 和 JAVA_PROBE_CLASSPATH 指向 Java 8 +以及 plugin-lib 的 test-classes/classes/测试 MySQL 驱动,启用两个 Java + 两个 Python 进程回归。 + +覆盖账户分配、响应丢失、签名失败、同操作参数冲突、网络 checkpoint 改变、节点计数回退、数据库 +不可用、uint32 边界、查询 mailbox 竞争、跨语言恢复 Java 在签名落库后强制退出的记录。 +两个 Dioxide 插件单独运行 DioxideClientFinalityTest,避免默认 BBC 集成测试误发真实链交易。 +插件构建加 -Dexec.skip=true,使用已提交 GCL 包装资源,本次不生成或变更链上合约代码。 + +## 发布与回滚 + +先备份服务包、配置和新增协调表,暂停相关提交,确认在途请求收敛;先安装协调存储和配置, +再部署服务包、插件、Python adapter,进行小批和并发验收。保留各文件 SHA-256、版本和测试 UCP。 +回滚先暂停提交、对账未决记录,再恢复旧服务包;协调数据不能回滚、删除或复用旧序号。 +旧代码存在已知并发缺陷,不能直接恢复旧版并发流量。 + +历史 TXN_ABORTED 业务本次仅列诊断清单,不补发、不修改原 UCP 或监管记录。合约内部异常与 +ISN abort 分开记录。公开 API 结构、链账户、合约地址、节点和公网端口保持原样。 + +## 复用的工程经验 + +预留必须发生在并发主体共享的持久边界;RPC compose 不等于预留,线程锁不等于跨进程锁。 +重试的单位是已持久化的提交操作,不是重新签名的业务请求。最终确认、执行成功、业务成功是 +不同事实,不能因外层哈希存在或确认等待超时就推导出业务成功。 diff --git a/acb-sdk/tools/dioxide-tx-coordinator/dioxide-tx.properties.example b/acb-sdk/tools/dioxide-tx-coordinator/dioxide-tx.properties.example new file mode 100644 index 00000000..1ae7b1bc --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/dioxide-tx.properties.example @@ -0,0 +1,8 @@ +# One networkId/checkpoint shared by all clients of the same physical chain. +# Keep the real file at /etc/antchain-bridge/dioxide-tx.properties with mode 0600. +networkId=dioxide-production +checkpointHeight=4120000 +checkpointHash=REPLACE_WITH_VERIFIED_FINALIZED_BLOCK_HASH +jdbcUrl=jdbc:mysql://127.0.0.1:3306/relayer_v2?connectTimeout=5000&socketTimeout=30000&useSSL=false +user=crosschain_tx +passwordFile=/etc/antchain-bridge/dioxide-tx.password diff --git a/acb-sdk/tools/dioxide-tx-coordinator/install_server_store.py b/acb-sdk/tools/dioxide-tx-coordinator/install_server_store.py new file mode 100644 index 00000000..2674988e --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/install_server_store.py @@ -0,0 +1,76 @@ +"""Install only the new coordinator tables/config on the Relayer host; never starts chain transactions.""" +import argparse +import json +import os +import re +import secrets +from pathlib import Path +from urllib.parse import urlsplit + +import pymysql +import yaml + + +def private_write(path, text): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as output: + output.write(text) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--relayer-config", required=True) + parser.add_argument("--schema-file", required=True) + parser.add_argument("--checkpoint-hash", required=True) + parser.add_argument("--checkpoint-height", type=int, required=True) + parser.add_argument("--network-id", required=True) + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + settings = yaml.safe_load(Path(args.relayer_config).read_text())["spring"]["datasource"] + uri = urlsplit(settings["url"].removeprefix("jdbc:")) + database = uri.path.lstrip("/") + if not re.fullmatch("[A-Za-z0-9_]+", database): + raise RuntimeError("unexpected database name") + connection = pymysql.connect(host=uri.hostname, port=uri.port or 3306, user=settings["username"], + password=settings["password"], database=database, autocommit=True) + root = Path("/etc/antchain-bridge") + config_path = root / "dioxide-tx.properties" + password_path = root / "dioxide-tx.password" + try: + with connection.cursor() as cursor: + cursor.execute("SELECT table_name FROM information_schema.tables WHERE table_schema=%s AND table_name LIKE 'bridge_tx_%%'", (database,)) + tables = [row[0] for row in cursor.fetchall()] + cursor.execute("SELECT COUNT(*) FROM mysql.user WHERE user='crosschain_tx' AND host='%'") + existing_user = cursor.fetchone()[0] + print(json.dumps({"database": database, "existingTables": tables, "existingUser": bool(existing_user), + "apply": args.apply, "configExists": config_path.exists()}), flush=True) + if not args.apply: + return + if tables or existing_user or config_path.exists() or password_path.exists(): + raise RuntimeError("existing coordination state found; inspect instead of overwriting") + root.mkdir(mode=0o700, parents=True, exist_ok=True) + password = secrets.token_urlsafe(36) + # Keep credential recoverable even if a later DDL fails; never print it. + private_write(str(password_path), password + "\n") + sql = Path(args.schema_file).read_text() + sql = "\n".join(line for line in sql.splitlines() if not line.lstrip().startswith("--")) + for statement in sql.split(";"): + if statement.strip(): + cursor.execute(statement) + cursor.execute("CREATE USER 'crosschain_tx'@'%%' IDENTIFIED BY %s", (password,)) + for table in ["bridge_tx_account", "bridge_tx_submission"]: + cursor.execute(f"GRANT SELECT,INSERT,UPDATE ON `{database}`.`{table}` TO 'crosschain_tx'@'%'") + private_write(str(config_path), ( + f"networkId={args.network_id}\ncheckpointHeight={args.checkpoint_height}\n" + f"checkpointHash={args.checkpoint_hash}\n" + f"jdbcUrl=jdbc:mysql://{uri.hostname}:{uri.port or 3306}/{database}?connectTimeout=5000&socketTimeout=30000&useSSL=false\n" + f"user=crosschain_tx\npasswordFile={password_path}\n" + )) + print(json.dumps({"installed": True, "tables": ["bridge_tx_account", "bridge_tx_submission"], + "config": str(config_path), "mode": "0600"})) + finally: + connection.close() + + +if __name__ == "__main__": + main() diff --git a/acb-sdk/tools/dioxide-tx-coordinator/scripts/dioxide_send_message.py b/acb-sdk/tools/dioxide-tx-coordinator/scripts/dioxide_send_message.py new file mode 100644 index 00000000..fc4e6c58 --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/scripts/dioxide_send_message.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Send a Dioxide AppContract message using a root-only BBC config. + +No key material is accepted on the command line or written to the receipt. +The finality check supports both Dioxide state families used by the node. +""" + +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path +from typing import Any + +from dioxide_python_sdk.client.account import DioxAccount +from dioxide_python_sdk.client.dioxclient import DioxClient +from dioxide_tx_coordinator import CoordinatedDioxClient + + +def as_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if hasattr(value, "to_dict"): + return value.to_dict() + return dict(value) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--operation-id", required=True, help="Stable ID for this send; reuse only when resuming it.") + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--dapp", required=True) + parser.add_argument("--app-contract", default="AppContract") + parser.add_argument("--target-domain", required=True) + parser.add_argument("--target-identity", required=True) + parser.add_argument("--message", required=True) + parser.add_argument("--timeout", type=int, default=180) + args = parser.parse_args() + + identity = args.target_identity.removeprefix("0x") + if len(identity) != 64: + raise ValueError("target identity must contain exactly 32 bytes of hex") + receiver = list(bytes.fromhex(identity)) + + config = json.loads(args.config.read_text(encoding="utf-8")) + account = DioxAccount.from_key(config["privateKey"]) + if account is None: + raise ValueError("invalid Dioxide key in BBC config") + client = CoordinatedDioxClient(DioxClient(config["rpcUrl"], config.get("wsRpc"))) + tx_hash = client.send_transaction( + account, + f"{args.dapp}.{args.app_contract}.sendUnorderedMessage", + { + "receiverDomain": list(args.target_domain.encode("utf-8")), + "receiver": receiver, + "message": list(args.message.encode("utf-8")), + }, + is_sync=False, + operation_id=args.operation_id, + ) + if not tx_hash: + raise RuntimeError("Dioxide returned an empty transaction hash") + print(json.dumps({"txHash": str(tx_hash), "submitted": True}), flush=True) + if not client.wait_for_transaction_confirmed(str(tx_hash), args.timeout * 1000): + raise TimeoutError("Dioxide transaction/relay tree did not finalize") + + deadline = time.monotonic() + args.timeout + last: dict[str, Any] = {} + while time.monotonic() < deadline: + last = as_dict(client.get_transaction(str(tx_hash))) + state = last.get("State") + confirm = last.get("ConfirmState") + if state in {"DUS_INVALID", "DUS_FORKED", "DUS_ARCHIVED_UNCLE"} or confirm in { + "TXN_RELAY_INVALIDED", + "TXN_ABORTED", + "TXN_EXPIRED", + }: + raise RuntimeError( + f"Dioxide transaction failed: state={state}, confirm={confirm}" + ) + if state in {"DUS_FINALIZED", "DUS_ARCHIVED"} or confirm in { + "TXN_FINALIZED", + "TXN_ARCHIVED", + }: + print( + json.dumps( + { + "txHash": str(tx_hash), + "state": state, + "confirmState": confirm, + "height": last.get("Height"), + } + ) + ) + return + time.sleep(2) + raise TimeoutError( + f"Dioxide transaction did not finalize: " + f"state={last.get('State')}, confirm={last.get('ConfirmState')}" + ) + + +if __name__ == "__main__": + main() diff --git a/acb-sdk/tools/dioxide-tx-coordinator/scripts/redeploy_dioxide_app_contract.py b/acb-sdk/tools/dioxide-tx-coordinator/scripts/redeploy_dioxide_app_contract.py new file mode 100644 index 00000000..0ffa116e --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/scripts/redeploy_dioxide_app_contract.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Redeploy and bind one Dioxide test AppContract without touching system CIDs.""" + +from __future__ import annotations + +import argparse +import json +import os +from collections import OrderedDict +from pathlib import Path + +from dioxide_python_sdk.client.account import DioxAccount +from dioxide_python_sdk.client.dioxclient import DioxClient, DioxError +from dioxide_tx_coordinator import CoordinatedDioxClient + +from redeploy_dioxide_system_contracts import ( + cid_address, + contract_info, + contract_state, + deploy_contracts_async, + submit, + wait_for, + wait_for_transaction, +) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--operation-id", required=True, help="Stable deployment operation ID; reuse when resuming.") + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--contract-name", default="AppContract") + parser.add_argument( + "--resume-deploy-tx", + help="resume a previously submitted deployment instead of submitting again", + ) + parser.add_argument("--monitored", action="store_true") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--compile-time", type=int, default=30) + args = parser.parse_args() + + config = json.loads(args.config.read_text(encoding="utf-8")) + account = DioxAccount.from_key(config["privateKey"]) + if account is None: + raise ValueError("invalid Dioxide private key") + client = CoordinatedDioxClient(DioxClient(config["rpcUrl"], config.get("wsRpc"))) + client.operation_prefix = args.operation_id + dapp = str(config["dappName"]) + contract_name = str(args.contract_name) + + try: + before = int( + contract_info(client, dapp, contract_name).get( + "ContractVersionID", 0 + ) + ) + except DioxError as error: + if error.code != 10007: + raise + before = 0 + if args.resume_deploy_tx: + deploy_tx = str(args.resume_deploy_tx) + print( + json.dumps( + {"deployTransaction": deploy_tx, "resumed": True} + ), + flush=True, + ) + else: + contracts = OrderedDict( + [(str(args.source), {"_owner": account.address})] + ) + deploy_tx = deploy_contracts_async( + client, account, dapp, contracts, args.compile_time + ) + print( + json.dumps( + {"deployTransaction": deploy_tx, "resumed": False} + ), + flush=True, + ) + wait_for_transaction(client, deploy_tx) + wait_for( + "new AppContract version", + lambda: int( + contract_info(client, dapp, contract_name).get( + "ContractVersionID", 0 + ) + ) + != before, + timeout=300, + ) + after = int( + contract_info(client, dapp, contract_name).get("ContractVersionID", 0) + ) + if after <= 0: + raise RuntimeError("new AppContract CID is missing") + + if args.monitored: + system_name = "Monitor" + cid_field = "monitorContractId" + function = f"{dapp}.{contract_name}.setMonitor" + bind_args = { + "_monitorContractId": int( + contract_info(client, dapp, system_name)["ContractVersionID"] + ), + } + bind_args["_monitorAddress"] = cid_address( + bind_args["_monitorContractId"] + ) + else: + system_name = "SDPMsg" + cid_field = "sdpContractId" + function = f"{dapp}.{contract_name}.setProtocol" + bind_args = { + "_protocolContractId": int( + contract_info(client, dapp, system_name)["ContractVersionID"] + ), + } + bind_args["_protocolAddress"] = cid_address( + bind_args["_protocolContractId"] + ) + + expected_system_cid = next( + value for key, value in bind_args.items() if key.endswith("ContractId") + ) + bind_tx = submit(client, account, function, bind_args) + wait_for_transaction(client, bind_tx) + wait_for( + "AppContract system binding", + lambda: int( + contract_state(client, dapp, contract_name).get(cid_field, 0) + ) + == expected_system_cid, + ) + + receipt = { + "dapp": dapp, + "contractName": contract_name, + "monitored": args.monitored, + "appContractBefore": before, + "appContractAfter": after, + "boundSystem": system_name, + "boundSystemCid": expected_system_cid, + "deployTransaction": deploy_tx, + "bindTransaction": bind_tx, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(receipt, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + os.chmod(args.output, 0o600) + print(json.dumps(receipt, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/acb-sdk/tools/dioxide-tx-coordinator/scripts/redeploy_dioxide_system_contracts.py b/acb-sdk/tools/dioxide-tx-coordinator/scripts/redeploy_dioxide_system_contracts.py new file mode 100644 index 00000000..c7b54e60 --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/scripts/redeploy_dioxide_system_contracts.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +"""Redeploy and bind Dioxide system contracts without embedding credentials. + +The script is intended to run on the Relayer host. It reads the existing +root-only BBC JSON configuration, deploys the repository's current GCL sources +into the existing dapp, rebinds AM/SDP/Monitor, and emits a non-secret JSON +receipt. It deliberately leaves Relayer metadata and anchor cursors untouched; +those are updated only after this receipt has been verified. +""" + +from __future__ import annotations + +import argparse +import json +import os +import time +from collections import OrderedDict +from pathlib import Path +from typing import Any, Callable + +from dioxide_python_sdk.client.account import ( + DioxAccount, + DioxAddress, + DioxAddressType, +) +from dioxide_python_sdk.client.contract import Scope +from dioxide_python_sdk.client.dioxclient import DioxClient +from dioxide_tx_coordinator import CoordinatedDioxClient + + +def as_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return value + if hasattr(value, "to_dict"): + return value.to_dict() + return dict(value) + + +def contract_info(client: DioxClient, dapp: str, name: str) -> dict[str, Any]: + return as_dict(client.get_contract_info(dapp, name)) + + +def contract_state(client: DioxClient, dapp: str, name: str) -> dict[str, Any]: + raw = as_dict(client.get_contract_state(dapp, name, Scope.Global, None)) + return as_dict(raw.get("State", raw)) + + +def wait_for(label: str, predicate: Callable[[], bool], timeout: int = 180) -> None: + deadline = time.monotonic() + timeout + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + if predicate(): + return + except Exception as error: # state relays can be temporarily unavailable + last_error = error + time.sleep(2) + suffix = f": {last_error}" if last_error else "" + raise TimeoutError(f"timed out waiting for {label}{suffix}") + + +def cid_address(cid: int) -> str: + return f"0x{cid:016X}:contract" + + +def require_file(root: Path, relative: str) -> Path: + path = root / relative + if not path.is_file(): + raise FileNotFoundError(path) + return path + + +def build_contracts( + root: Path, account_address: str, monitored: bool, deploy_app: bool +) -> OrderedDict[str, dict[str, Any] | None]: + relative_paths = [ + "interfaces/IAuthMessage.gcl", + "interfaces/IContractUsingSDP.gcl", + "interfaces/ISDPMessage.gcl", + "interfaces/ISubProtocol.gcl", + "lib/am/AMLib.gcl", + "lib/sdp/SDPLib.gcl", + "lib/utils/BytesToTypes.gcl", + "lib/utils/SizeOf.gcl", + "lib/utils/TLVUtils.gcl", + "lib/utils/TypesToBytes.gcl", + "lib/utils/Utils.gcl", + ] + if monitored: + relative_paths[1:1] = [ + "interfaces/IContractUsingMonitor.gcl", + "interfaces/IMonitor.gcl", + ] + relative_paths.insert(6, "lib/monitor/MonitorLib.gcl") + + contracts: OrderedDict[str, dict[str, Any] | None] = OrderedDict() + for relative in relative_paths: + contracts[str(require_file(root, relative))] = None + contracts[str(require_file(root, "AuthMsg.gcl"))] = { + "_owner": account_address, + "_relayer": account_address, + } + contracts[str(require_file(root, "SDPMsg.gcl"))] = { + "_owner": account_address + } + if monitored: + contracts[str(require_file(root, "Monitor.gcl"))] = { + "_owner": account_address + } + if deploy_app: + contracts[str(require_file(root, "AppContract.gcl"))] = { + "_owner": account_address + } + return contracts + + +def submit( + client: DioxClient, + account: DioxAccount, + function: str, + args: dict[str, Any], +) -> str: + tx_hash = client.send_transaction( + account, function, args, is_sync=False, + operation_id=client.operation_prefix + ":bind:" + function, + ) + if not tx_hash: + raise RuntimeError(f"empty transaction hash for {function}") + return str(tx_hash) + + +def transaction_snapshot(client: DioxClient, tx_hash: str) -> dict[str, Any]: + return as_dict(client.get_transaction(tx_hash)) + + +def transaction_finalized(transaction: dict[str, Any]) -> bool: + return transaction.get("ConfirmState") in {"TXN_FINALIZED", "TXN_ARCHIVED"} or ( + transaction.get("State") in {"DUS_FINALIZED", "DUS_ARCHIVED"} + ) + + +def transaction_failed(transaction: dict[str, Any]) -> bool: + invocation = as_dict(transaction.get("Invocation")) + return invocation.get("Status") in { + "IVKRET_FALSE", + "IVKRET_EXCEPTION_THROWN", + "IVKRET_CONTRACT_UNAVAILABLE", + } or transaction.get("ConfirmState") in { + "TXN_RELAY_INVALIDED", + "TXN_ABORTED", + "TXN_EXPIRED", + } or transaction.get("State") in { + "DUS_INVALID", + "DUS_FORKED", + "DUS_ARCHIVED_UNCLE", + } + + +def wait_for_transaction( + client: DioxClient, tx_hash: str, timeout: int = 300 +) -> None: + if not client.wait_for_transaction_confirmed(tx_hash, timeout * 1000): + raise TimeoutError(f"transaction/relay tree {tx_hash} did not finalize") + deadline = time.monotonic() + timeout + last: dict[str, Any] = {} + while time.monotonic() < deadline: + last = transaction_snapshot(client, tx_hash) + if transaction_failed(last): + raise RuntimeError( + f"transaction {tx_hash} failed: " + f"state={last.get('State')} confirm={last.get('ConfirmState')}" + ) + if transaction_finalized(last): + return + time.sleep(2) + raise TimeoutError( + f"transaction {tx_hash} did not finalize: " + f"state={last.get('State')} confirm={last.get('ConfirmState')}" + ) + + +def deploy_contracts_async( + client: DioxClient, + account: DioxAccount, + dapp: str, + contracts: OrderedDict[str, dict[str, Any] | None], + compile_time: int, +) -> str: + codes: list[str] = [] + constructor_args: list[str] = [] + for path, constructor in contracts.items(): + codes.append(Path(path).read_text(encoding="utf-8")) + constructor_args.append( + "" if constructor is None else json.dumps(constructor) + ) + dapp_address = DioxAddress(None, DioxAddressType.DAPP) + if not dapp_address.set_delegatee_from_string(dapp): + raise ValueError(f"invalid Dioxide dapp name: {dapp}") + tx_hash = client.send_transaction( + account, "core.delegation.deploy_contracts", { + "code": codes, + "cargs": constructor_args, + "time": compile_time, + }, + delegatee=dapp_address.address, is_sync=False, + operation_id=client.operation_prefix + ":deploy:" + dapp, + ) + if not tx_hash: + raise RuntimeError("Dioxide returned an empty deployment transaction hash") + return str(tx_hash) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--operation-id", required=True, help="Stable deployment operation ID; reuse when resuming.") + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--source-dir", type=Path, required=True) + parser.add_argument("--domain", required=True) + parser.add_argument("--monitored", action="store_true") + parser.add_argument( + "--redeploy-app", + action="store_true", + help="deploy the current AppContract and bind it to the new SDP/Monitor", + ) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--compile-time", type=int, default=90) + parser.add_argument( + "--resume-deploy-tx", + help="resume a previously submitted deployment transaction without resubmitting", + ) + args = parser.parse_args() + + config = json.loads(args.config.read_text(encoding="utf-8")) + private_key = config.get("privateKey") + rpc_url = config.get("rpcUrl") + ws_rpc = config.get("wsRpc") + dapp = config.get("dappName") + if not all((private_key, rpc_url, dapp)): + raise ValueError("BBC config is missing privateKey, rpcUrl, or dappName") + + account = DioxAccount.from_key(private_key) + if account is None: + raise ValueError("invalid Dioxide private key") + client = CoordinatedDioxClient(DioxClient(rpc_url, ws_rpc) if ws_rpc else DioxClient(rpc_url)) + client.operation_prefix = args.operation_id + overview = as_dict(client.get_overview()) + before_height = int(overview.get("HeadHeight", 0)) + + contract_names = ["AuthMsg", "SDPMsg"] + if args.monitored: + contract_names.append("Monitor") + if args.redeploy_app: + contract_names.append("AppContract") + before = { + name: contract_info(client, dapp, name).get("ContractVersionID") + for name in contract_names + } + + contracts = build_contracts( + args.source_dir, account.address, args.monitored, args.redeploy_app + ) + deploy_tx_hash = args.resume_deploy_tx or deploy_contracts_async( + client, account, dapp, contracts, args.compile_time + ) + print(f"deployment transaction: {deploy_tx_hash}", flush=True) + wait_for_transaction(client, str(deploy_tx_hash)) + + wait_for( + "new contract versions", + lambda: all( + str(contract_info(client, dapp, name).get("ContractVersionID")) + != str(before.get(name)) + for name in contract_names + ), + timeout=300, + ) + + after: dict[str, int] = {} + for name in contract_names: + info = contract_info(client, dapp, name) + cid = int(info.get("ContractVersionID", 0)) + if cid <= 0: + raise RuntimeError(f"missing ContractVersionID for {dapp}.{name}") + after[name] = cid + + am_cid = after["AuthMsg"] + sdp_cid = after["SDPMsg"] + tx_hashes: dict[str, str] = {"deploy": str(deploy_tx_hash)} + + tx_hashes["sdp_set_am"] = submit( + client, + account, + f"{dapp}.SDPMsg.setAmContract", + {"_amContractId": am_cid, "_amAddress": cid_address(am_cid)}, + ) + wait_for( + "SDP AM binding", + lambda: int(contract_state(client, dapp, "SDPMsg").get("amContractId", 0)) + == am_cid, + ) + + tx_hashes["sdp_set_domain"] = submit( + client, + account, + f"{dapp}.SDPMsg.setLocalDomain", + {"domain": list(args.domain.encode("utf-8"))}, + ) + wait_for( + "SDP local domain", + lambda: contract_state(client, dapp, "SDPMsg").get("localDomain") + == list(args.domain.encode("utf-8")), + ) + + if args.monitored: + monitor_cid = after["Monitor"] + tx_hashes["monitor_set_sdp"] = submit( + client, + account, + f"{dapp}.Monitor.setProtocol", + { + "_sdpContractId": sdp_cid, + "_sdpAddress": cid_address(sdp_cid), + }, + ) + wait_for( + "Monitor SDP binding", + lambda: int( + contract_state(client, dapp, "Monitor").get("sdpContractId", 0) + ) + == sdp_cid, + ) + tx_hashes["sdp_set_monitor"] = submit( + client, + account, + f"{dapp}.SDPMsg.setMonitorContract", + { + "_monitorContractId": monitor_cid, + "_monitorAddress": cid_address(monitor_cid), + }, + ) + wait_for( + "SDP Monitor binding", + lambda: int( + contract_state(client, dapp, "SDPMsg").get( + "monitorContractId", 0 + ) + ) + == monitor_cid, + ) + + tx_hashes["am_set_sdp"] = submit( + client, + account, + f"{dapp}.AuthMsg.setProtocol", + { + "protocolID": sdp_cid, + "protocolAddress": cid_address(sdp_cid), + "protocolType": 0, + }, + ) + wait_for( + "AM SDP binding", + lambda: str(sdp_cid) + in json.dumps(contract_state(client, dapp, "AuthMsg"), sort_keys=True), + ) + + app_cid = ( + after["AppContract"] + if args.redeploy_app + else int( + contract_info(client, dapp, "AppContract").get( + "ContractVersionID", 0 + ) + ) + ) + if app_cid: + if args.monitored: + monitor_cid = after["Monitor"] + tx_hashes["app_set_monitor"] = submit( + client, + account, + f"{dapp}.AppContract.setMonitor", + { + "_monitorContractId": monitor_cid, + "_monitorAddress": cid_address(monitor_cid), + }, + ) + wait_for( + "App Monitor binding", + lambda: int( + contract_state(client, dapp, "AppContract").get( + "monitorContractId", 0 + ) + ) + == monitor_cid, + ) + else: + tx_hashes["app_set_sdp"] = submit( + client, + account, + f"{dapp}.AppContract.setProtocol", + { + "_protocolContractId": sdp_cid, + "_protocolAddress": cid_address(sdp_cid), + }, + ) + + after_height = int(as_dict(client.get_overview()).get("HeadHeight", 0)) + receipt = { + "domain": args.domain, + "dapp": dapp, + "monitored": args.monitored, + "headHeightBefore": before_height, + "headHeightAfter": after_height, + "contractsBefore": before, + "contractsAfter": after, + "appContract": app_cid or None, + "transactions": tx_hashes, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + os.chmod(args.output, 0o600) + print(json.dumps(receipt, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() From 5d7c1f6f243201707326863b8ad34630240e19e5 Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:26:35 +0800 Subject: [PATCH 05/16] fix: load the coordinator JDBC driver through the active plugin class loader --- .../JdbcTransactionCoordinator.java | 6 +++++- .../JdbcTransactionCoordinatorTest.java | 20 +++++++++++++++++++ .../plugins/dioxide/core/DioxideClient.java | 2 +- .../plugins/dioxide2/core/DioxideClient.java | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/acb-sdk/antchain-bridge-plugin-lib/src/main/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinator.java b/acb-sdk/antchain-bridge-plugin-lib/src/main/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinator.java index c4d63086..a4d42e21 100644 --- a/acb-sdk/antchain-bridge-plugin-lib/src/main/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinator.java +++ b/acb-sdk/antchain-bridge-plugin-lib/src/main/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinator.java @@ -48,13 +48,17 @@ public static Properties readConfig(String filename) throws Exception { } public static JdbcTransactionCoordinator fromProperties(Properties p) throws Exception { + return fromProperties(p, Thread.currentThread().getContextClassLoader()); + } + + public static JdbcTransactionCoordinator fromProperties(Properties p, ClassLoader pluginLoader) throws Exception { final String url = required(p, "jdbcUrl"); final Properties credentials = new Properties(); credentials.setProperty("user", required(p, "user")); credentials.setProperty("password", new String(Files.readAllBytes(Paths.get( required(p, "passwordFile"))), StandardCharsets.UTF_8).trim()); // DriverManager's caller class-loader filtering is unsuitable for PF4J plugin drivers. - final Driver driver = (Driver) Class.forName("com.mysql.cj.jdbc.Driver").newInstance(); + final Driver driver = (Driver) Class.forName("com.mysql.cj.jdbc.Driver", true, pluginLoader).newInstance(); return new JdbcTransactionCoordinator(() -> { Connection c = driver.connect(url, credentials); if (c == null) { throw new SQLException("unsupported transaction coordinator JDBC URL"); } diff --git a/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinatorTest.java b/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinatorTest.java index 7097ea62..55c26b2d 100644 --- a/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinatorTest.java +++ b/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/JdbcTransactionCoordinatorTest.java @@ -125,4 +125,24 @@ public String broadcast(byte[] bytes) throws Exception { catch (IllegalStateException expected) { assertTrue(expected.getMessage().contains("regressed")); } assertEquals(1, node.composeCalls.get()); } + + @Test public void jdbcDriverIsLoadedFromThePluginClassLoader() throws Exception { + java.nio.file.Path password = java.nio.file.Files.createTempFile("isn-test-", ".password"); + try { + Properties config = new Properties(); + config.setProperty("jdbcUrl", System.getProperty("isn.test.jdbc")); + config.setProperty("user", "root"); config.setProperty("passwordFile", password.toString()); + config.setProperty("networkId", network); config.setProperty("checkpointHash", "checkpoint"); + java.util.concurrent.atomic.AtomicBoolean consulted = new java.util.concurrent.atomic.AtomicBoolean(); + ClassLoader loader = new ClassLoader(getClass().getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals("com.mysql.cj.jdbc.Driver")) { consulted.set(true); } + return super.loadClass(name, resolve); + } + }; + assertEquals("tx-181", JdbcTransactionCoordinator.fromProperties(config, loader) + .submit("op", "account", PAYLOAD, new Node())); + assertTrue(consulted.get()); + } finally { java.nio.file.Files.deleteIfExists(password); } + } } diff --git a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java index 41ea87a7..42de4be8 100644 --- a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java +++ b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java @@ -67,7 +67,7 @@ private synchronized JdbcTransactionCoordinator coordinator() { } Properties p = JdbcTransactionCoordinator.readConfig(filename); coordinatorCheckpointHeight = Long.parseLong(JdbcTransactionCoordinator.required(p, "checkpointHeight")); - transactionCoordinator = JdbcTransactionCoordinator.fromProperties(p); + transactionCoordinator = JdbcTransactionCoordinator.fromProperties(p, getClass().getClassLoader()); } return transactionCoordinator; } diff --git a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java index 2d80cbbf..ef4ccf16 100644 --- a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java +++ b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java @@ -67,7 +67,7 @@ private synchronized JdbcTransactionCoordinator coordinator() { } Properties p = JdbcTransactionCoordinator.readConfig(filename); coordinatorCheckpointHeight = Long.parseLong(JdbcTransactionCoordinator.required(p, "checkpointHeight")); - transactionCoordinator = JdbcTransactionCoordinator.fromProperties(p); + transactionCoordinator = JdbcTransactionCoordinator.fromProperties(p, getClass().getClassLoader()); } return transactionCoordinator; } From 57c536737916c14cda227c2bcea6b8f031792622 Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:32:03 +0800 Subject: [PATCH 06/16] test: add a bounded send-only Mychain burst harness --- .../scripts/MychainBurstSend.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/scripts/MychainBurstSend.java diff --git a/acb-sdk/tools/dioxide-tx-coordinator/scripts/MychainBurstSend.java b/acb-sdk/tools/dioxide-tx-coordinator/scripts/MychainBurstSend.java new file mode 100644 index 00000000..99533d68 --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/scripts/MychainBurstSend.java @@ -0,0 +1,59 @@ +import com.alipay.antchain.bridge.commons.core.base.SendResponseResult; +import com.alipay.antchain.bridge.plugins.mychain020.sdk.Mychain020Client; +import com.alipay.mychain.sdk.crypto.hash.Hash; +import com.alipay.mychain.sdk.domain.account.Identity; +import com.alipay.mychain.sdk.vm.EVMParameter; +import org.slf4j.helpers.NOPLogger; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; + +/** Sends only to an existing dedicated test application; never deploys or changes Monitor control. */ +public final class MychainBurstSend { + public static void main(String[] args) throws Exception { + if (args.length != 6 || !args[3].matches("[0-9a-fA-F]{64}") + || !args[4].matches("[a-zA-Z0-9_-]{1,80}")) { + throw new IllegalArgumentException("CONFIG TEST_SENDER DOMAIN TARGET_HEX UNIQUE_BATCH COUNT(1..32)"); + } + int count = Integer.parseInt(args[5]); + if (count < 1 || count > 32 || !args[1].startsWith("MonitorSender_")) { + throw new IllegalArgumentException("requires dedicated MonitorSender test app and count 1..32"); + } + Mychain020Client client = new Mychain020Client(Files.readAllBytes(Paths.get(args[0])), NOPLogger.NOP_LOGGER); + if (!client.startup()) throw new IllegalStateException("SDK startup failed"); + ExecutorService pool = Executors.newFixedThreadPool(count); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + long begin = System.nanoTime(); + try { + for (int i = 0; i < count; i++) { + final int index = i; + futures.add(pool.submit(() -> { + start.await(); + EVMParameter p = new EVMParameter("sendUnordered(identity,string,bytes)"); + p.addIdentity(new Identity(new Hash(args[3]))); + p.addString(args[2]); + p.addBytes((args[4] + "-" + index).getBytes(StandardCharsets.UTF_8)); + return client.callContract(args[1], p, true); + })); + } + start.countDown(); + for (int i = 0; i < count; i++) { + SendResponseResult r = futures.get(i).get(90, TimeUnit.SECONDS); + System.out.println("{\"batch\":\"" + args[4] + "\",\"index\":" + i + + ",\"txHash\":\"" + r.getTxId() + "\",\"confirmed\":" + r.isConfirmed() + + ",\"success\":" + r.isSuccess() + "}"); + if (!r.isSuccess() || !r.isConfirmed()) throw new IllegalStateException("source execution failed at " + i); + } + System.out.println("{\"count\":" + count + ",\"burstElapsedMs\":" + + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - begin) + "}"); + } finally { + pool.shutdownNow(); + pool.awaitTermination(95, TimeUnit.SECONDS); + client.shutdown(); + } + } +} From 4a010f14d9de59a21cba5ec50e8dfe467b54294b Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:41:28 +0800 Subject: [PATCH 07/16] test: cover Python reserved ISN, delegation and full execution outcomes --- .../dioxide-tx-coordinator/test_wrapper.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/test_wrapper.py diff --git a/acb-sdk/tools/dioxide-tx-coordinator/test_wrapper.py b/acb-sdk/tools/dioxide-tx-coordinator/test_wrapper.py new file mode 100644 index 00000000..44e8bc19 --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/test_wrapper.py @@ -0,0 +1,63 @@ +import base64 +import unittest +from unittest.mock import Mock +from dioxide_tx_coordinator import CoordinatedDioxClient + + +class WrapperTest(unittest.TestCase): + def wrapper(self, responses): + wrapper = CoordinatedDioxClient.__new__(CoordinatedDioxClient) + wrapper.client = Mock() + wrapper.client.make_request.side_effect = responses + wrapper.coordinator = Mock() + wrapper.coordinator.config = {"checkpointHeight": "123"} + return wrapper + + def test_explicit_reserved_isn_and_delegatee(self): + raw = bytes(8) + (181).to_bytes(4, "little") + b"payload" + wrapper = self.wrapper([{"Hash": "checkpoint"}, {"ISN": 181}, + {"TxData": base64.b64encode(raw).decode()}, {"Hash": "tx"}]) + user = Mock() + user.address = "signer:ed25519" + user.sign_diox_transaction.return_value = b"signed-fixture" + def submit(op, account, payload, transport): + self.assertEqual("deployment-1", op) + self.assertEqual("testapp:dapp", account) + self.assertEqual("checkpoint", transport.checkpoint()) + self.assertEqual(181, transport.current_isn(account)) + self.assertEqual(b"signed-fixture", transport.compose_and_sign(181)) + return transport.broadcast(b"signed-fixture") + wrapper.coordinator.submit.side_effect = submit + self.assertEqual("tx", wrapper.send_transaction(user, "core.delegation.deploy_contracts", {}, + delegatee="testapp", operation_id="deployment-1")) + compose = wrapper.client.make_request.call_args_list[2].args[1] + self.assertEqual(181, compose["isn"]) + self.assertNotIn("sender", compose) + user.sign_diox_transaction.assert_called_once_with(raw) + + def test_sync_timeout_is_not_success(self): + wrapper = self.wrapper([]) + wrapper.coordinator.submit.return_value = "tx" + wrapper.wait_for_transaction_confirmed = Mock(return_value=False) + user = Mock(address="account") + with self.assertRaises(TimeoutError): + wrapper.send_transaction(user, "app.send", {}, is_sync=True, operation_id="one") + + def test_embedded_failure_and_abort_are_terminal(self): + for tx in [ + {"ConfirmState": "TXN_ABORTED"}, + {"State": "DUS_ARCHIVED", "Relays": [{"Invocation": {"Status": "IVKRET_EXCEPTION_THROWN"}}]}, + ]: + wrapper = self.wrapper([tx]) + with self.assertRaises(RuntimeError): + wrapper.wait_for_transaction_confirmed("root") + wrapper.coordinator.record_outcome.assert_called_once_with("root", False) + + def test_waits_for_referenced_child_before_success(self): + wrapper = self.wrapper([ + {"State": "DUS_ARCHIVED", "Relays": [{"Invocation": {"Status": "IVKRET_SUCCESS", "Relays": ["child:0"]}}]}, + {"State": "DUS_ARCHIVED", "Invocation": {"Status": "IVKRET_SUCCESS"}}, + ]) + self.assertTrue(wrapper.wait_for_transaction_confirmed("root")) + wrapper.coordinator.record_outcome.assert_called_once_with("root", True) + self.assertEqual({"hash": "child"}, wrapper.client.make_request.call_args.args[1]) From 4df2958d539ada3c29ef97c86da8134e5d4eb760 Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:50:32 +0800 Subject: [PATCH 08/16] fix(dioxide): preserve relay member indices and isolate concurrent receipt trees --- .../plugins/dioxide/core/DioxideClient.java | 56 +++++++++++++++++-- .../core/DioxideClientFinalityTest.java | 48 ++++++++++++++-- .../plugins/dioxide2/core/DioxideClient.java | 56 +++++++++++++++++-- .../core/DioxideClientFinalityTest.java | 48 ++++++++++++++-- .../dioxide_tx_coordinator.py | 16 +++++- .../dioxide-tx-coordinator/test_wrapper.py | 13 ++++- 6 files changed, 211 insertions(+), 26 deletions(-) diff --git a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java index 42de4be8..416376df 100644 --- a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java +++ b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java @@ -212,15 +212,19 @@ public CrossChainMessageReceipt getCrossChainMessageReceipt(DioxideTransaction d List tx2Hashes = dioxideTransaction.getInvocation().getRelays().stream() .filter(Objects::nonNull) - .map(DioxideClient::normalizeRelayTxHash) .filter(StrUtil::isNotEmpty) .distinct() .toList(); boolean eventStructurePending = false; List eventTargetTxHashes = new ArrayList<>(); + Map relayGroups = new HashMap<>(); for (String tx2Hash : tx2Hashes) { - JSONObject tx2 = getTransactionJonObjectByHash(tx2Hash); + String groupHash = normalizeRelayTxHash(tx2Hash); + if (!relayGroups.containsKey(groupHash)) { + relayGroups.put(groupHash, getTransactionJonObjectByHash(groupHash)); + } + JSONObject tx2 = scopeRelayGroup(relayGroups.get(groupHash), tx2Hash); RelayEventInspection relayEventInspection = inspectRelayEvents(tx2, tx2Hash); if (relayEventInspection.state() == RelayEventState.FAILED) { return buildCrossChainMessageReceipt( @@ -239,7 +243,8 @@ public CrossChainMessageReceipt getCrossChainMessageReceipt(DioxideTransaction d : null; List eventTargetTxs = new ArrayList<>(); for (String eventTargetTxHash : eventTargetTxHashes.stream().distinct().toList()) { - DioxideTransaction eventTargetTx = getTransactionByHash(eventTargetTxHash); + DioxideTransaction eventTargetTx = scopeTransactionReference( + getTransactionByHash(normalizeRelayTxHash(eventTargetTxHash)), eventTargetTxHash); if (eventTargetTx == null) { if (pendingEventFinality == null) { pendingEventFinality = new TxFinalityResult( @@ -946,6 +951,7 @@ static TxFinalityResult evaluateTxFinalityWithRelays( Queue queue = new ArrayDeque<>(); Set queuedTxHashes = new HashSet<>(); + Map resolvedGroups = new HashMap<>(); queue.add(dioxideTransaction); if (StrUtil.isNotEmpty(dioxideTransaction.getTxHash())) { queuedTxHashes.add(normalizeRelayTxHash(dioxideTransaction.getTxHash())); @@ -982,10 +988,13 @@ static TxFinalityResult evaluateTxFinalityWithRelays( } continue; } - if (!queuedTxHashes.add(relayTxHash)) { + if (!queuedTxHashes.add(relayTxReference)) { continue; } - DioxideTransaction relayTx = transactionResolver.apply(relayTxHash); + if (!resolvedGroups.containsKey(relayTxHash)) { + resolvedGroups.put(relayTxHash, transactionResolver.apply(relayTxHash)); + } + DioxideTransaction relayTx = scopeTransactionReference(resolvedGroups.get(relayTxHash), relayTxReference); if (relayTx == null) { if (pendingResult == null) { pendingResult = new TxFinalityResult(TxFinalityState.PENDING, relayTxHash, ""); @@ -1005,6 +1014,37 @@ static TxFinalityResult evaluateTxFinalityWithRelays( : pendingResult; } + /** A group may contain many unrelated business transactions: preserve its indexed member. */ + static DioxideTransaction scopeTransactionReference(DioxideTransaction group, String reference) { + if (group == null || reference == null) { return null; } + int separator = reference.indexOf(':'); + if (separator < 0) { return group; } + try { + int index = Integer.parseInt(reference.substring(separator + 1)); + if (index < 0 || group.getEmbeddedRelays() == null || index >= group.getEmbeddedRelays().size()) { return null; } + DioxideTransaction member = group.getEmbeddedRelays().get(index); + if (member == null) { return null; } + return DioxideTransaction.builder().txHash(reference).state(group.getState()) + .confirmState(group.getConfirmState()).target(member.getTarget()).embeddedRelays(List.of(member)).build(); + } catch (NumberFormatException e) { return null; } + } + + static JSONObject scopeRelayGroup(JSONObject group, String reference) { + if (group == null || reference == null) { return null; } + int separator = reference.indexOf(':'); + if (separator < 0) { return group; } + try { + int index = Integer.parseInt(reference.substring(separator + 1)); + JSONArray members = group.getJSONArray("Relays"); + if (index < 0 || members == null || index >= members.size()) { return null; } + JSONObject result = new JSONObject(group); + JSONArray selected = new JSONArray(); + selected.add(members.get(index)); + result.put("Relays", selected); + return result; + } catch (NumberFormatException e) { return null; } + } + static String normalizeRelayTxHash(String relayTxReference) { if (StrUtil.isEmpty(relayTxReference)) { return ""; @@ -1070,7 +1110,6 @@ static RelayEventInspection inspectRelayEvents(JSONObject relayGroup, String rel continue; } embeddedInvocation.getInvocation().getRelays().stream() - .map(DioxideClient::normalizeRelayTxHash) .filter(StrUtil::isNotEmpty) .forEach(eventTargetTxHashes::add); } @@ -1079,6 +1118,11 @@ static RelayEventInspection inspectRelayEvents(JSONObject relayGroup, String rel if (CollUtil.isNotEmpty(distinctTargetTxHashes)) { return new RelayEventInspection(RelayEventState.READY, distinctTargetTxHashes, ""); } + // One indexed member (e.g. AM lambda 8) may legitimately emit no event. + // The combined receipt still requires a protocol event from another member of THIS root. + if (relayGroupFinalized && relayGroupTxHash.contains(":")) { + return new RelayEventInspection(RelayEventState.READY, List.of(), ""); + } return relayGroupFinalized ? failedRelayEventInspection(relayGroupTxHash, "produced no protocol event transaction") : new RelayEventInspection(RelayEventState.PENDING, List.of(), ""); diff --git a/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java b/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java index c7e5b260..19bd8284 100644 --- a/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java +++ b/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java @@ -11,6 +11,44 @@ public class DioxideClientFinalityTest { + @Test public void indexedGroupDoesNotInspectAnotherBusinessFailure() { + DioxideTransaction bad = DioxideTransaction.builder().invocation( + DioxideTransaction.Invocation.builder().status("IVKRET_EXCEPTION_THROWN").build()).build(); + DioxideTransaction good = DioxideTransaction.builder().invocation( + DioxideTransaction.Invocation.builder().status("IVKRET_SUCCESS").relays(List.of("child")).build()).build(); + DioxideTransaction group = DioxideTransaction.builder().txHash("group").state("DUS_ARCHIVED") + .embeddedRelays(List.of(bad, good)).build(); + DioxideTransaction child = DioxideTransaction.builder().txHash("child").state("DUS_ARCHIVED").build(); + DioxideTransaction root = DioxideTransaction.builder().txHash("root").state("DUS_ARCHIVED") + .invocation(DioxideTransaction.Invocation.builder().relays(List.of("group:1")).build()).build(); + Map nodes = Map.of("group", group, "child", child); + Assert.assertEquals(DioxideClient.TxFinalityState.FINALIZED, + DioxideClient.evaluateTxFinalityWithRelays(root, nodes::get).state()); + root.getInvocation().setRelays(List.of("group:0")); + Assert.assertEquals(DioxideClient.TxFinalityState.FAILED, + DioxideClient.evaluateTxFinalityWithRelays(root, nodes::get).state()); + root.getInvocation().setRelays(List.of("group:9")); + Assert.assertEquals(DioxideClient.TxFinalityState.PENDING, + DioxideClient.evaluateTxFinalityWithRelays(root, nodes::get).state()); + } + + @Test public void differentMembersOfSameGroupRemainDistinctAndFetchOnce() { + DioxideTransaction first = DioxideTransaction.builder().invocation( + DioxideTransaction.Invocation.builder().status("IVKRET_SUCCESS").build()).build(); + DioxideTransaction second = DioxideTransaction.builder().invocation( + DioxideTransaction.Invocation.builder().status("IVKRET_SUCCESS").relays(List.of("pending")).build()).build(); + DioxideTransaction group = DioxideTransaction.builder().state("DUS_ARCHIVED").embeddedRelays(List.of(first, second)).build(); + DioxideTransaction root = DioxideTransaction.builder().txHash("root").state("DUS_ARCHIVED") + .invocation(DioxideTransaction.Invocation.builder().relays(List.of("group:0", "group:1")).build()).build(); + java.util.concurrent.atomic.AtomicInteger reads = new java.util.concurrent.atomic.AtomicInteger(); + Assert.assertEquals(DioxideClient.TxFinalityState.PENDING, + DioxideClient.evaluateTxFinalityWithRelays(root, hash -> { + if ("group".equals(hash)) { reads.incrementAndGet(); return group; } + return null; + }).state()); + Assert.assertEquals(1, reads.get()); + } + @Test public void testEmbeddedInvocationFailureAndChildFinality() { DioxideTransaction root = DioxideTransaction.builder().txHash("root").confirmState("TXN_ARCHIVED") @@ -18,7 +56,7 @@ public void testEmbeddedInvocationFailureAndChildFinality() { .invocation(DioxideTransaction.Invocation.builder().status("IVKRET_EXCEPTION_THROWN").build()).build())).build(); Assert.assertEquals(DioxideClient.TxFinalityState.FAILED, evaluate(root, Map.of()).state()); root.setEmbeddedRelays(List.of(DioxideTransaction.builder() - .invocation(DioxideTransaction.Invocation.builder().status("IVKRET_SUCCESS").relays(List.of("child:0")).build()).build())); + .invocation(DioxideTransaction.Invocation.builder().status("IVKRET_SUCCESS").relays(List.of("child")).build()).build())); DioxideTransaction child = DioxideTransaction.builder().txHash("child").confirmState("TXN_READY").build(); Assert.assertEquals(DioxideClient.TxFinalityState.PENDING, evaluate(root, Map.of("child", child)).state()); child.setConfirmState("TXN_ARCHIVED"); @@ -95,7 +133,7 @@ public void testEvaluateFinalityAcrossRelayTree() { DioxideTransaction root = transactionWithRelays( "root", DioxideTypes.TxnConfirmState.TXN_FINALIZED.name(), - List.of("child:0") + List.of("child") ); DioxideTransaction pendingChild = transactionWithRelays( "child", @@ -147,12 +185,12 @@ public void testRelayHashNormalizationAndCycleProtection() { DioxideTransaction root = transactionWithRelays( "root", DioxideTypes.TxnConfirmState.TXN_FINALIZED.name(), - List.of("child:7") + List.of("child") ); DioxideTransaction child = transactionWithRelays( "child", DioxideTypes.TxnConfirmState.TXN_FINALIZED.name(), - List.of("root:2") + List.of("root") ); Assert.assertEquals("child", DioxideClient.normalizeRelayTxHash("child:7")); @@ -193,7 +231,7 @@ public void testFinalizedRelayGroupReturnsProtocolEventTransactions() { DioxideClient.RelayEventInspection inspection = DioxideClient.inspectRelayEvents(relayGroup, "relay-group"); Assert.assertEquals(DioxideClient.RelayEventState.READY, inspection.state()); - Assert.assertEquals(List.of("target-a", "target-b"), inspection.eventTargetTxHashes()); + Assert.assertEquals(List.of("target-a:0", "target-b", "target-a:1"), inspection.eventTargetTxHashes()); Assert.assertEquals("", inspection.errorMessage()); } diff --git a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java index ef4ccf16..f60adadd 100644 --- a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java +++ b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java @@ -215,15 +215,19 @@ public CrossChainMessageReceipt getCrossChainMessageReceipt(DioxideTransaction d List tx2Hashes = dioxideTransaction.getInvocation().getRelays().stream() .filter(Objects::nonNull) - .map(DioxideClient::normalizeRelayTxHash) .filter(StrUtil::isNotEmpty) .distinct() .toList(); boolean eventStructurePending = false; List eventTargetTxHashes = new ArrayList<>(); + Map relayGroups = new HashMap<>(); for (String tx2Hash : tx2Hashes) { - JSONObject tx2 = getTransactionJonObjectByHash(tx2Hash); + String groupHash = normalizeRelayTxHash(tx2Hash); + if (!relayGroups.containsKey(groupHash)) { + relayGroups.put(groupHash, getTransactionJonObjectByHash(groupHash)); + } + JSONObject tx2 = scopeRelayGroup(relayGroups.get(groupHash), tx2Hash); RelayEventInspection relayEventInspection = inspectRelayEvents(tx2, tx2Hash); if (relayEventInspection.state() == RelayEventState.FAILED) { return buildCrossChainMessageReceipt( @@ -242,7 +246,8 @@ public CrossChainMessageReceipt getCrossChainMessageReceipt(DioxideTransaction d : null; List eventTargetTxs = new ArrayList<>(); for (String eventTargetTxHash : eventTargetTxHashes.stream().distinct().toList()) { - DioxideTransaction eventTargetTx = getTransactionByHash(eventTargetTxHash); + DioxideTransaction eventTargetTx = scopeTransactionReference( + getTransactionByHash(normalizeRelayTxHash(eventTargetTxHash)), eventTargetTxHash); if (eventTargetTx == null) { if (pendingEventFinality == null) { pendingEventFinality = new TxFinalityResult( @@ -1045,6 +1050,7 @@ static TxFinalityResult evaluateTxFinalityWithRelays( Queue queue = new ArrayDeque<>(); Set queuedTxHashes = new HashSet<>(); + Map resolvedGroups = new HashMap<>(); queue.add(dioxideTransaction); if (StrUtil.isNotEmpty(dioxideTransaction.getTxHash())) { queuedTxHashes.add(normalizeRelayTxHash(dioxideTransaction.getTxHash())); @@ -1081,10 +1087,13 @@ static TxFinalityResult evaluateTxFinalityWithRelays( } continue; } - if (!queuedTxHashes.add(relayTxHash)) { + if (!queuedTxHashes.add(relayTxReference)) { continue; } - DioxideTransaction relayTx = transactionResolver.apply(relayTxHash); + if (!resolvedGroups.containsKey(relayTxHash)) { + resolvedGroups.put(relayTxHash, transactionResolver.apply(relayTxHash)); + } + DioxideTransaction relayTx = scopeTransactionReference(resolvedGroups.get(relayTxHash), relayTxReference); if (relayTx == null) { if (pendingResult == null) { pendingResult = new TxFinalityResult(TxFinalityState.PENDING, relayTxHash, ""); @@ -1104,6 +1113,37 @@ static TxFinalityResult evaluateTxFinalityWithRelays( : pendingResult; } + /** A group may contain many unrelated business transactions: preserve its indexed member. */ + static DioxideTransaction scopeTransactionReference(DioxideTransaction group, String reference) { + if (group == null || reference == null) { return null; } + int separator = reference.indexOf(':'); + if (separator < 0) { return group; } + try { + int index = Integer.parseInt(reference.substring(separator + 1)); + if (index < 0 || group.getEmbeddedRelays() == null || index >= group.getEmbeddedRelays().size()) { return null; } + DioxideTransaction member = group.getEmbeddedRelays().get(index); + if (member == null) { return null; } + return DioxideTransaction.builder().txHash(reference).state(group.getState()) + .confirmState(group.getConfirmState()).target(member.getTarget()).embeddedRelays(List.of(member)).build(); + } catch (NumberFormatException e) { return null; } + } + + static JSONObject scopeRelayGroup(JSONObject group, String reference) { + if (group == null || reference == null) { return null; } + int separator = reference.indexOf(':'); + if (separator < 0) { return group; } + try { + int index = Integer.parseInt(reference.substring(separator + 1)); + JSONArray members = group.getJSONArray("Relays"); + if (index < 0 || members == null || index >= members.size()) { return null; } + JSONObject result = new JSONObject(group); + JSONArray selected = new JSONArray(); + selected.add(members.get(index)); + result.put("Relays", selected); + return result; + } catch (NumberFormatException e) { return null; } + } + static String normalizeRelayTxHash(String relayTxReference) { if (StrUtil.isEmpty(relayTxReference)) { return ""; @@ -1169,7 +1209,6 @@ static RelayEventInspection inspectRelayEvents(JSONObject relayGroup, String rel continue; } embeddedInvocation.getInvocation().getRelays().stream() - .map(DioxideClient::normalizeRelayTxHash) .filter(StrUtil::isNotEmpty) .forEach(eventTargetTxHashes::add); } @@ -1178,6 +1217,11 @@ static RelayEventInspection inspectRelayEvents(JSONObject relayGroup, String rel if (CollUtil.isNotEmpty(distinctTargetTxHashes)) { return new RelayEventInspection(RelayEventState.READY, distinctTargetTxHashes, ""); } + // One indexed member (e.g. AM lambda 8) may legitimately emit no event. + // The combined receipt still requires a protocol event from another member of THIS root. + if (relayGroupFinalized && relayGroupTxHash.contains(":")) { + return new RelayEventInspection(RelayEventState.READY, List.of(), ""); + } return relayGroupFinalized ? failedRelayEventInspection(relayGroupTxHash, "produced no protocol event transaction") : new RelayEventInspection(RelayEventState.PENDING, List.of(), ""); diff --git a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java index 68c79307..f8e7d715 100644 --- a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java +++ b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java @@ -11,6 +11,44 @@ public class DioxideClientFinalityTest { + @Test public void indexedGroupDoesNotInspectAnotherBusinessFailure() { + DioxideTransaction bad = DioxideTransaction.builder().invocation( + DioxideTransaction.Invocation.builder().status("IVKRET_EXCEPTION_THROWN").build()).build(); + DioxideTransaction good = DioxideTransaction.builder().invocation( + DioxideTransaction.Invocation.builder().status("IVKRET_SUCCESS").relays(List.of("child")).build()).build(); + DioxideTransaction group = DioxideTransaction.builder().txHash("group").state("DUS_ARCHIVED") + .embeddedRelays(List.of(bad, good)).build(); + DioxideTransaction child = DioxideTransaction.builder().txHash("child").state("DUS_ARCHIVED").build(); + DioxideTransaction root = DioxideTransaction.builder().txHash("root").state("DUS_ARCHIVED") + .invocation(DioxideTransaction.Invocation.builder().relays(List.of("group:1")).build()).build(); + Map nodes = Map.of("group", group, "child", child); + Assert.assertEquals(DioxideClient.TxFinalityState.FINALIZED, + DioxideClient.evaluateTxFinalityWithRelays(root, nodes::get).state()); + root.getInvocation().setRelays(List.of("group:0")); + Assert.assertEquals(DioxideClient.TxFinalityState.FAILED, + DioxideClient.evaluateTxFinalityWithRelays(root, nodes::get).state()); + root.getInvocation().setRelays(List.of("group:9")); + Assert.assertEquals(DioxideClient.TxFinalityState.PENDING, + DioxideClient.evaluateTxFinalityWithRelays(root, nodes::get).state()); + } + + @Test public void differentMembersOfSameGroupRemainDistinctAndFetchOnce() { + DioxideTransaction first = DioxideTransaction.builder().invocation( + DioxideTransaction.Invocation.builder().status("IVKRET_SUCCESS").build()).build(); + DioxideTransaction second = DioxideTransaction.builder().invocation( + DioxideTransaction.Invocation.builder().status("IVKRET_SUCCESS").relays(List.of("pending")).build()).build(); + DioxideTransaction group = DioxideTransaction.builder().state("DUS_ARCHIVED").embeddedRelays(List.of(first, second)).build(); + DioxideTransaction root = DioxideTransaction.builder().txHash("root").state("DUS_ARCHIVED") + .invocation(DioxideTransaction.Invocation.builder().relays(List.of("group:0", "group:1")).build()).build(); + java.util.concurrent.atomic.AtomicInteger reads = new java.util.concurrent.atomic.AtomicInteger(); + Assert.assertEquals(DioxideClient.TxFinalityState.PENDING, + DioxideClient.evaluateTxFinalityWithRelays(root, hash -> { + if ("group".equals(hash)) { reads.incrementAndGet(); return group; } + return null; + }).state()); + Assert.assertEquals(1, reads.get()); + } + @Test public void testEmbeddedInvocationFailureAndChildFinality() { DioxideTransaction root = DioxideTransaction.builder().txHash("root").confirmState("TXN_ARCHIVED") @@ -18,7 +56,7 @@ public void testEmbeddedInvocationFailureAndChildFinality() { .invocation(DioxideTransaction.Invocation.builder().status("IVKRET_EXCEPTION_THROWN").build()).build())).build(); Assert.assertEquals(DioxideClient.TxFinalityState.FAILED, evaluate(root, Map.of()).state()); root.setEmbeddedRelays(List.of(DioxideTransaction.builder() - .invocation(DioxideTransaction.Invocation.builder().status("IVKRET_SUCCESS").relays(List.of("child:0")).build()).build())); + .invocation(DioxideTransaction.Invocation.builder().status("IVKRET_SUCCESS").relays(List.of("child")).build()).build())); DioxideTransaction child = DioxideTransaction.builder().txHash("child").confirmState("TXN_READY").build(); Assert.assertEquals(DioxideClient.TxFinalityState.PENDING, evaluate(root, Map.of("child", child)).state()); child.setConfirmState("TXN_ARCHIVED"); @@ -95,7 +133,7 @@ public void testEvaluateFinalityAcrossRelayTree() { DioxideTransaction root = transactionWithRelays( "root", DioxideTypes.TxnConfirmState.TXN_FINALIZED.name(), - List.of("child:0") + List.of("child") ); DioxideTransaction pendingChild = transactionWithRelays( "child", @@ -147,12 +185,12 @@ public void testRelayHashNormalizationAndCycleProtection() { DioxideTransaction root = transactionWithRelays( "root", DioxideTypes.TxnConfirmState.TXN_FINALIZED.name(), - List.of("child:7") + List.of("child") ); DioxideTransaction child = transactionWithRelays( "child", DioxideTypes.TxnConfirmState.TXN_FINALIZED.name(), - List.of("root:2") + List.of("root") ); Assert.assertEquals("child", DioxideClient.normalizeRelayTxHash("child:7")); @@ -193,7 +231,7 @@ public void testFinalizedRelayGroupReturnsProtocolEventTransactions() { DioxideClient.RelayEventInspection inspection = DioxideClient.inspectRelayEvents(relayGroup, "relay-group"); Assert.assertEquals(DioxideClient.RelayEventState.READY, inspection.state()); - Assert.assertEquals(List.of("target-a", "target-b"), inspection.eventTargetTxHashes()); + Assert.assertEquals(List.of("target-a:0", "target-b", "target-a:1"), inspection.eventTargetTxHashes()); Assert.assertEquals("", inspection.errorMessage()); } diff --git a/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py b/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py index 2a66bb11..7ae107f6 100644 --- a/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py +++ b/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py @@ -205,16 +205,26 @@ def broadcast(self, signed): def wait_for_transaction_confirmed(self, tx_hash, timeout=120000): deadline = time.monotonic() + timeout / 1000 while time.monotonic() < deadline: - queue, seen, pending = [tx_hash], set(), False + queue, seen, pending, resolved = [tx_hash], set(), False, {} while queue: - current = queue.pop().split(":")[0] + current = queue.pop() if current in seen: continue seen.add(current) - tx = self.client.make_request("dx.transaction", {"hash": current}) + base_hash = current.split(":", 1)[0] + if base_hash not in resolved: + resolved[base_hash] = self.client.make_request("dx.transaction", {"hash": base_hash}) + tx = resolved[base_hash] if not isinstance(tx, dict): pending = True continue + if ":" in current: + suffix = current.split(":", 1)[1] + members = tx.get("Relays") or [] + if not suffix.isdecimal() or int(suffix) >= len(members): + pending = True + continue + tx = dict(tx, Invocation=None, Relays=[members[int(suffix)]]) if tx.get("ConfirmState") in {"TXN_ABORTED", "TXN_EXPIRED", "TXN_RELAY_INVALIDED"} or tx.get("State") in {"DUS_INVALID", "DUS_FORKED", "DUS_ARCHIVED_UNCLE"}: self.coordinator.record_outcome(tx_hash, False) raise RuntimeError(f"Dioxide transaction failed: {current} ({tx.get('ConfirmState')})") diff --git a/acb-sdk/tools/dioxide-tx-coordinator/test_wrapper.py b/acb-sdk/tools/dioxide-tx-coordinator/test_wrapper.py index 44e8bc19..3de49535 100644 --- a/acb-sdk/tools/dioxide-tx-coordinator/test_wrapper.py +++ b/acb-sdk/tools/dioxide-tx-coordinator/test_wrapper.py @@ -5,6 +5,17 @@ class WrapperTest(unittest.TestCase): + def test_indexed_group_ignores_unrelated_failed_business(self): + wrapper = self.wrapper([ + {"State": "DUS_ARCHIVED", "Invocation": {"Status": "IVKRET_SUCCESS", "Relays": ["group:1"]}}, + {"State": "DUS_ARCHIVED", "Relays": [ + {"Invocation": {"Status": "IVKRET_EXCEPTION_THROWN"}}, + {"Invocation": {"Status": "IVKRET_SUCCESS"}}, + ]}, + ]) + self.assertTrue(wrapper.wait_for_transaction_confirmed("root")) + wrapper.coordinator.record_outcome.assert_called_once_with("root", True) + def wrapper(self, responses): wrapper = CoordinatedDioxClient.__new__(CoordinatedDioxClient) wrapper.client = Mock() @@ -55,7 +66,7 @@ def test_embedded_failure_and_abort_are_terminal(self): def test_waits_for_referenced_child_before_success(self): wrapper = self.wrapper([ - {"State": "DUS_ARCHIVED", "Relays": [{"Invocation": {"Status": "IVKRET_SUCCESS", "Relays": ["child:0"]}}]}, + {"State": "DUS_ARCHIVED", "Relays": [{"Invocation": {"Status": "IVKRET_SUCCESS", "Relays": ["child"]}}]}, {"State": "DUS_ARCHIVED", "Invocation": {"Status": "IVKRET_SUCCESS"}}, ]) self.assertTrue(wrapper.wait_for_transaction_confirmed("root")) From 96adb492154e374dd45f212ec285bbd8bdf3bd2b Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:56:33 +0800 Subject: [PATCH 09/16] fix(dioxide): keep relay group cache immutable while selecting members --- .../bridge/plugins/dioxide/core/DioxideClient.java | 3 ++- .../plugins/dioxide/core/DioxideClientFinalityTest.java | 9 +++++++++ .../bridge/plugins/dioxide2/core/DioxideClient.java | 3 ++- .../plugins/dioxide2/core/DioxideClientFinalityTest.java | 9 +++++++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java index 416376df..317dfc98 100644 --- a/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java +++ b/acb-sdk/pluginset/dioxide/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClient.java @@ -1037,7 +1037,8 @@ static JSONObject scopeRelayGroup(JSONObject group, String reference) { int index = Integer.parseInt(reference.substring(separator + 1)); JSONArray members = group.getJSONArray("Relays"); if (index < 0 || members == null || index >= members.size()) { return null; } - JSONObject result = new JSONObject(group); + JSONObject result = new JSONObject(); + result.putAll(group); // JSONObject(Map) aliases its backing map; never shrink the cached group. JSONArray selected = new JSONArray(); selected.add(members.get(index)); result.put("Relays", selected); diff --git a/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java b/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java index 19bd8284..6d669931 100644 --- a/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java +++ b/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java @@ -10,6 +10,15 @@ import java.util.Map; public class DioxideClientFinalityTest { + @Test public void scopingJsonMembersDoesNotMutateTheCachedGroup() { + com.alibaba.fastjson.JSONObject group = com.alibaba.fastjson.JSON.parseObject( + "{\"State\":\"DUS_ARCHIVED\",\"Relays\":[{\"Function\":\"first\"},{\"Function\":\"second\"}]}"); + Assert.assertEquals("first", DioxideClient.scopeRelayGroup(group, "group:0") + .getJSONArray("Relays").getJSONObject(0).getString("Function")); + Assert.assertEquals("second", DioxideClient.scopeRelayGroup(group, "group:1") + .getJSONArray("Relays").getJSONObject(0).getString("Function")); + Assert.assertEquals(2, group.getJSONArray("Relays").size()); + } @Test public void indexedGroupDoesNotInspectAnotherBusinessFailure() { DioxideTransaction bad = DioxideTransaction.builder().invocation( diff --git a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java index f60adadd..14b253ef 100644 --- a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java +++ b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClient.java @@ -1136,7 +1136,8 @@ static JSONObject scopeRelayGroup(JSONObject group, String reference) { int index = Integer.parseInt(reference.substring(separator + 1)); JSONArray members = group.getJSONArray("Relays"); if (index < 0 || members == null || index >= members.size()) { return null; } - JSONObject result = new JSONObject(group); + JSONObject result = new JSONObject(); + result.putAll(group); // JSONObject(Map) aliases its backing map; never shrink the cached group. JSONArray selected = new JSONArray(); selected.add(members.get(index)); result.put("Relays", selected); diff --git a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java index f8e7d715..a31e7e50 100644 --- a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java +++ b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java @@ -10,6 +10,15 @@ import java.util.Map; public class DioxideClientFinalityTest { + @Test public void scopingJsonMembersDoesNotMutateTheCachedGroup() { + com.alibaba.fastjson.JSONObject group = com.alibaba.fastjson.JSON.parseObject( + "{\"State\":\"DUS_ARCHIVED\",\"Relays\":[{\"Function\":\"first\"},{\"Function\":\"second\"}]}"); + Assert.assertEquals("first", DioxideClient.scopeRelayGroup(group, "group:0") + .getJSONArray("Relays").getJSONObject(0).getString("Function")); + Assert.assertEquals("second", DioxideClient.scopeRelayGroup(group, "group:1") + .getJSONArray("Relays").getJSONObject(0).getString("Function")); + Assert.assertEquals(2, group.getJSONArray("Relays").size()); + } @Test public void indexedGroupDoesNotInspectAnotherBusinessFailure() { DioxideTransaction bad = DioxideTransaction.builder().invocation( From eb9e5eccc14f4a50008566b1bc8c24fe10e47f8c Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:02:59 +0800 Subject: [PATCH 10/16] test(dioxide): exercise complete indexed receipts through RPC JSON --- .../core/DioxideClientFinalityTest.java | 42 +++++++++++++++++++ .../core/DioxideClientFinalityTest.java | 42 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java b/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java index 6d669931..dd7e36a2 100644 --- a/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java +++ b/acb-sdk/pluginset/dioxide/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide/core/DioxideClientFinalityTest.java @@ -10,6 +10,48 @@ import java.util.Map; public class DioxideClientFinalityTest { + @Test public void completeReceiptScopesIndexedMembersAcrossRealRpcJson() throws Exception { + String rootJson = "{\"Hash\":\"root\",\"Height\":1,\"State\":\"DUS_ARCHIVED\",\"Invocation\":{\"Status\":\"IVKRET_SUCCESS\",\"Relays\":[\"group:0\",\"group:1\"]}}"; + String groupJson = "{\"Hash\":\"group\",\"State\":\"DUS_ARCHIVED\",\"Relays\":[" + + "{\"Function\":\"lambda8\",\"Invocation\":{\"Status\":\"IVKRET_SUCCESS\"}}," + + "{\"Function\":\"lambda9\",\"Invocation\":{\"Status\":\"IVKRET_SUCCESS\",\"Relays\":[\"event\"]}}," + + "{\"Function\":\"unrelated\",\"Invocation\":{\"Status\":\"IVKRET_EXCEPTION_THROWN\"}}]}"; + String eventJson = "{\"Hash\":\"event\",\"State\":\"DUS_ARCHIVED\",\"Target\":\"recvMessageInProtocol:name\"}"; + java.util.Map replies = java.util.Map.of("root", rootJson, "group", groupJson, "event", eventJson); + com.sun.net.httpserver.HttpServer server = com.sun.net.httpserver.HttpServer.create( + new java.net.InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api", exchange -> { + String input = new String(exchange.getRequestBody().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + String value = "{\"HeadHeight\":10}"; + if (exchange.getRequestURI().getQuery().contains("dx.transaction")) { + value = replies.getOrDefault(com.alibaba.fastjson.JSON.parseObject(input).getString("hash"), "{}"); + } + byte[] response = ("{\"ret\":" + value + "}").getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + try (java.io.OutputStream stream = exchange.getResponseBody()) { stream.write(response); } + }); + server.start(); + DioxideClient client = null; + try { + com.alipay.antchain.bridge.plugins.dioxide.conf.DioxideConfig config = + new com.alipay.antchain.bridge.plugins.dioxide.conf.DioxideConfig(); + config.setRpcUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/api"); + config.setPrivateKey(java.util.Base64.getEncoder().encodeToString(new byte[32])); + config.setDappName("Test"); + config.setTxCoordinatorConfigFile("/nonexistent/isn-test-config"); + client = new DioxideClient(config, org.slf4j.helpers.NOPLogger.NOP_LOGGER); + com.alipay.antchain.bridge.commons.core.base.CrossChainMessageReceipt receipt = + client.getCrossChainMessageReceipt(client.getTransactionByHash("root")); + Assert.assertTrue(receipt.getErrorMsg(), receipt.isConfirmed()); + Assert.assertTrue(receipt.getErrorMsg(), receipt.isSuccessful()); + Assert.assertEquals("root", receipt.getTxhash()); + } finally { + if (client != null) { client.shutdown(); } + server.stop(0); + } + } + + @Test public void scopingJsonMembersDoesNotMutateTheCachedGroup() { com.alibaba.fastjson.JSONObject group = com.alibaba.fastjson.JSON.parseObject( "{\"State\":\"DUS_ARCHIVED\",\"Relays\":[{\"Function\":\"first\"},{\"Function\":\"second\"}]}"); diff --git a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java index a31e7e50..9b8a5704 100644 --- a/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java +++ b/acb-sdk/pluginset/dioxide2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/dioxide2/core/DioxideClientFinalityTest.java @@ -10,6 +10,48 @@ import java.util.Map; public class DioxideClientFinalityTest { + @Test public void completeReceiptScopesIndexedMembersAcrossRealRpcJson() throws Exception { + String rootJson = "{\"Hash\":\"root\",\"Height\":1,\"State\":\"DUS_ARCHIVED\",\"Invocation\":{\"Status\":\"IVKRET_SUCCESS\",\"Relays\":[\"group:0\",\"group:1\"]}}"; + String groupJson = "{\"Hash\":\"group\",\"State\":\"DUS_ARCHIVED\",\"Relays\":[" + + "{\"Function\":\"lambda8\",\"Invocation\":{\"Status\":\"IVKRET_SUCCESS\"}}," + + "{\"Function\":\"lambda9\",\"Invocation\":{\"Status\":\"IVKRET_SUCCESS\",\"Relays\":[\"event\"]}}," + + "{\"Function\":\"unrelated\",\"Invocation\":{\"Status\":\"IVKRET_EXCEPTION_THROWN\"}}]}"; + String eventJson = "{\"Hash\":\"event\",\"State\":\"DUS_ARCHIVED\",\"Target\":\"recvMessageInProtocol:name\"}"; + java.util.Map replies = java.util.Map.of("root", rootJson, "group", groupJson, "event", eventJson); + com.sun.net.httpserver.HttpServer server = com.sun.net.httpserver.HttpServer.create( + new java.net.InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api", exchange -> { + String input = new String(exchange.getRequestBody().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + String value = "{\"HeadHeight\":10}"; + if (exchange.getRequestURI().getQuery().contains("dx.transaction")) { + value = replies.getOrDefault(com.alibaba.fastjson.JSON.parseObject(input).getString("hash"), "{}"); + } + byte[] response = ("{\"ret\":" + value + "}").getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + try (java.io.OutputStream stream = exchange.getResponseBody()) { stream.write(response); } + }); + server.start(); + DioxideClient client = null; + try { + com.alipay.antchain.bridge.plugins.dioxide2.conf.DioxideConfig config = + new com.alipay.antchain.bridge.plugins.dioxide2.conf.DioxideConfig(); + config.setRpcUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/api"); + config.setPrivateKey(java.util.Base64.getEncoder().encodeToString(new byte[32])); + config.setDappName("Test"); + config.setTxCoordinatorConfigFile("/nonexistent/isn-test-config"); + client = new DioxideClient(config, org.slf4j.helpers.NOPLogger.NOP_LOGGER); + com.alipay.antchain.bridge.commons.core.base.CrossChainMessageReceipt receipt = + client.getCrossChainMessageReceipt(client.getTransactionByHash("root")); + Assert.assertTrue(receipt.getErrorMsg(), receipt.isConfirmed()); + Assert.assertTrue(receipt.getErrorMsg(), receipt.isSuccessful()); + Assert.assertEquals("root", receipt.getTxhash()); + } finally { + if (client != null) { client.shutdown(); } + server.stop(0); + } + } + + @Test public void scopingJsonMembersDoesNotMutateTheCachedGroup() { com.alibaba.fastjson.JSONObject group = com.alibaba.fastjson.JSON.parseObject( "{\"State\":\"DUS_ARCHIVED\",\"Relays\":[{\"Function\":\"first\"},{\"Function\":\"second\"}]}"); From c9849d0748860bd2f2687a76f457e042662ee6ef Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:07:08 +0800 Subject: [PATCH 11/16] fix(dioxide): coordinate funding and dapp creation stages --- .../dioxide_tx_coordinator.py | 12 ++++++++++++ .../tools/dioxide-tx-coordinator/test_wrapper.py | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py b/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py index 7ae107f6..88d31f37 100644 --- a/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py +++ b/acb-sdk/tools/dioxide-tx-coordinator/dioxide_tx_coordinator.py @@ -247,6 +247,18 @@ def inspect(value): time.sleep(1) return False + def mint_dio(self, user, amount, sync=True, timeout=120000, operation_id=None): + return self.send_transaction(user, "core.coin.mint", {"Amount": str(amount)}, + is_sync=sync, timeout=timeout, operation_id=operation_id) + + def create_dapp(self, user, dapp_name, deposit_amount, sync=True, timeout=120000, operation_id=None): + tx_hash = self.send_transaction(user, "core.delegation.create", + {"Type": 10, "Name": str(dapp_name), "Deposit": str(deposit_amount)}, + is_sync=sync, timeout=timeout, operation_id=operation_id) + if sync and not self.client.wait_for_dapp_deployed(tx_hash, timeout): + raise TimeoutError(f"Dioxide dapp deployment did not complete: {tx_hash}") + return tx_hash, True if sync else None + def deploy_contracts(self, dapp_name, delegator, contracts, compile_time=None, operation_id=None): args = {"code": [], "cargs": []} for filename, constructor in contracts.items(): diff --git a/acb-sdk/tools/dioxide-tx-coordinator/test_wrapper.py b/acb-sdk/tools/dioxide-tx-coordinator/test_wrapper.py index 3de49535..cd5f751c 100644 --- a/acb-sdk/tools/dioxide-tx-coordinator/test_wrapper.py +++ b/acb-sdk/tools/dioxide-tx-coordinator/test_wrapper.py @@ -5,6 +5,22 @@ class WrapperTest(unittest.TestCase): + def test_dapp_setup_uses_coordinated_submission_and_checks_wait(self): + wrapper = self.wrapper([]) + wrapper.send_transaction = Mock(return_value="same-hash") + user = Mock() + self.assertEqual("same-hash", wrapper.mint_dio(user, 100, operation_id="setup:fund")) + wrapper.send_transaction.assert_called_with(user, "core.coin.mint", {"Amount": "100"}, + is_sync=True, timeout=120000, operation_id="setup:fund") + wrapper.client.wait_for_dapp_deployed.return_value = True + self.assertEqual(("same-hash", True), wrapper.create_dapp(user, "test", 10, operation_id="setup:dapp")) + wrapper.send_transaction.assert_called_with(user, "core.delegation.create", + {"Type": 10, "Name": "test", "Deposit": "10"}, + is_sync=True, timeout=120000, operation_id="setup:dapp") + wrapper.client.wait_for_dapp_deployed.return_value = False + with self.assertRaises(TimeoutError): + wrapper.create_dapp(user, "test", 10, operation_id="setup:dapp") + def test_indexed_group_ignores_unrelated_failed_business(self): wrapper = self.wrapper([ {"State": "DUS_ARCHIVED", "Invocation": {"Status": "IVKRET_SUCCESS", "Relays": ["group:1"]}}, From 9f9460bf881c9049a01e76f4e9b02387ff7be6f2 Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:10:13 +0800 Subject: [PATCH 12/16] test: verify cross-process query isolation and pre-sign crash recovery --- .../transactions/CoordinatorProcessProbe.java | 22 ++++++++++- .../tools/dioxide-tx-coordinator/README.md | 12 ++++++ .../test_coordinator.py | 39 ++++++++++++++++++- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/CoordinatorProcessProbe.java b/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/CoordinatorProcessProbe.java index 01a2aaf8..d165e3a8 100644 --- a/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/CoordinatorProcessProbe.java +++ b/acb-sdk/antchain-bridge-plugin-lib/src/test/java/com/alipay/antchain/bridge/plugins/lib/transactions/CoordinatorProcessProbe.java @@ -10,11 +10,31 @@ public static void main(String[] args) throws Exception { JdbcTransactionCoordinator coordinator = new JdbcTransactionCoordinator( () -> DriverManager.getConnection(args[0], "root", ""), args[1], "fixture"); for (int i = 0; i < Integer.parseInt(args[3]); i++) { + if (args.length > 4 && "query".equals(args[4])) { + final String value = args[2] + "-" + i; + System.out.println(coordinator.withQueryLock("sdp|account", () -> { + try (java.sql.Connection c = DriverManager.getConnection(args[0], "root", ""); + java.sql.PreparedStatement write = c.prepareStatement("UPDATE coordinator_test_mailbox SET value=? WHERE network_id=?"); + java.sql.PreparedStatement read = c.prepareStatement("SELECT value FROM coordinator_test_mailbox WHERE network_id=?")) { + write.setString(1, value); write.setString(2, args[1]); write.executeUpdate(); + Thread.sleep(10); + read.setString(1, args[1]); + try (java.sql.ResultSet r = read.executeQuery()) { + if (!r.next() || !value.equals(r.getString(1))) { throw new IllegalStateException("mailbox overwritten"); } + } + } + return value; + })); + continue; + } String hash = coordinator.submit(args[2] + "-" + i, "account", new byte[]{1, 2, 3}, new JdbcTransactionCoordinator.Transport() { public String checkpoint() { return "fixture"; } public long currentIsn(String account) { return 181; } - public byte[] composeAndSign(long isn) { return ByteBuffer.allocate(8).putLong(isn).array(); } + public byte[] composeAndSign(long isn) { + if (args.length > 4 && "crash-before-sign".equals(args[4])) { Runtime.getRuntime().halt(18); } + return ByteBuffer.allocate(8).putLong(isn).array(); + } public String broadcast(byte[] signed) { if (args.length > 4 && "crash".equals(args[4])) { Runtime.getRuntime().halt(17); } return "tx-" + ByteBuffer.wrap(signed).getLong(); diff --git a/acb-sdk/tools/dioxide-tx-coordinator/README.md b/acb-sdk/tools/dioxide-tx-coordinator/README.md index eb3ee8b4..a419e907 100644 --- a/acb-sdk/tools/dioxide-tx-coordinator/README.md +++ b/acb-sdk/tools/dioxide-tx-coordinator/README.md @@ -51,6 +51,9 @@ python scripts/dioxide_send_message.py --operation-id test-20260904-001 \ 以上是示意参数,不应直接对真实业务合约执行。部署脚本也要求 operation-id,并为部署和各个绑定 阶段使用独立标识;本次 ISN 修复不执行任何合约重部署。 +Python 的测试币准备、Dapp 创建也使用协调入口及独立阶段 ID。Dapp 等待失败会抛异常, +不能把底层 SDK 的 false 返回值当作完成。首次部署前先确认相应脚本也使用同一版共享模块。 + - SIGNED:签名已经持久化,可能尚未广播,也可能广播后进程退出。 - UNKNOWN:广播结果未知;只能查询或重新广播原始签名字节,不得换 ISN。 - BROADCAST:已拿到哈希,原提交标识直接返回同一哈希。 @@ -69,6 +72,8 @@ Python 测试以 ISN_TEST_MYSQL=1 开启;JAVA_PROBE_JAVA 和 JAVA_PROBE_CLASSP 覆盖账户分配、响应丢失、签名失败、同操作参数冲突、网络 checkpoint 改变、节点计数回退、数据库 不可用、uint32 边界、查询 mailbox 竞争、跨语言恢复 Java 在签名落库后强制退出的记录。 +跨进程测试还覆盖签名前退出后的事务回滚,以及两个 Java、两个 Python 进程对同一查询 +mailbox 的 32 次写入/等待/读回,结果不能串线。所有 fixture 表只在本地 isn_test 库创建。 两个 Dioxide 插件单独运行 DioxideClientFinalityTest,避免默认 BBC 集成测试误发真实链交易。 插件构建加 -Dexec.skip=true,使用已提交 GCL 包装资源,本次不生成或变更链上合约代码。 @@ -87,3 +92,10 @@ ISN abort 分开记录。公开 API 结构、链账户、合约地址、节点 预留必须发生在并发主体共享的持久边界;RPC compose 不等于预留,线程锁不等于跨进程锁。 重试的单位是已持久化的提交操作,不是重新签名的业务请求。最终确认、执行成功、业务成功是 不同事实,不能因外层哈希存在或确认等待超时就推导出业务成功。 + +Dioxide relay group 可能含其他业务的交易,必须保留 groupHash:数组下标,只遍历引用成员。 +组缓存可以共享,但选择成员时不能修改缓存本身;Fastjson JSONObject(Map) 使用原 Map, +不是复制。回归测试通过真实 RPC JSON 检查一组中的两个有效成员,不受另一个失败成员影响。 + +运维服务恢复也需要校验依赖:systemd 停止 Runner 可能连带停止 Backend,启动 Runner +并不保证 Backend 自动恢复。每次切换后显式检查两个服务及公共查询接口。 diff --git a/acb-sdk/tools/dioxide-tx-coordinator/test_coordinator.py b/acb-sdk/tools/dioxide-tx-coordinator/test_coordinator.py index d64db340..19ef10ea 100644 --- a/acb-sdk/tools/dioxide-tx-coordinator/test_coordinator.py +++ b/acb-sdk/tools/dioxide-tx-coordinator/test_coordinator.py @@ -3,6 +3,7 @@ import subprocess import unittest import uuid +import time import pymysql from dioxide_tx_coordinator import Coordinator @@ -36,6 +37,20 @@ def python_process(network, prefix): return [c.submit(f"{prefix}-{i}", "account:ed25519", bytes([1, 2, 3]), Transport()) for i in range(32)] +def python_query_process(network, prefix): + values = [] + for i in range(8): + own = f"{prefix}-{i}" + with coordinator(network).lock("query", "sdp|account") as c, c.cursor() as q: + q.execute("UPDATE coordinator_test_mailbox SET value=%s WHERE network_id=%s", (own, network)) + time.sleep(0.01) + q.execute("SELECT value FROM coordinator_test_mailbox WHERE network_id=%s", (network,)) + if q.fetchone()[0] != own: + raise AssertionError("mailbox overwritten") + values.append(own) + return values + + @unittest.skipUnless(os.environ.get("ISN_TEST_MYSQL") == "1", "requires disposable MySQL") class CoordinatorTest(unittest.TestCase): def setUp(self): @@ -80,9 +95,31 @@ def test_two_java_and_two_python_processes_and_crash_recovery(self): # The Python implementation resumes Java's committed signed bytes, not a new ISN. recovered = coordinator(self.network).submit("crash-0", "account", bytes([1, 2, 3]), Transport()) self.assertEqual("tx-309", recovered) + crashed_before_sign = subprocess.run(args + ["pre-crash", "1", "crash-before-sign"], capture_output=True, timeout=30) + self.assertEqual(18, crashed_before_sign.returncode) + self.assertEqual("tx-310", coordinator(self.network).submit("pre-crash-0", "account", bytes([1, 2, 3]), Transport())) with connection() as c, c.cursor() as cursor: cursor.execute("SELECT COUNT(*),COUNT(DISTINCT isn) FROM bridge_tx_submission WHERE network_id=%s", (self.network,)) - self.assertEqual((129, 129), cursor.fetchone()) + self.assertEqual((130, 130), cursor.fetchone()) + + @unittest.skipUnless(os.environ.get("JAVA_PROBE_CLASSPATH"), "requires compiled Java fixture") + def test_query_mailbox_across_two_java_and_two_python_processes(self): + with connection() as c, c.cursor() as q: + q.execute("CREATE TABLE IF NOT EXISTS coordinator_test_mailbox (network_id VARCHAR(96) PRIMARY KEY, value VARCHAR(96))") + q.execute("INSERT INTO coordinator_test_mailbox VALUES (%s,'')", (self.network,)) + args = [os.environ["JAVA_PROBE_JAVA"], "-cp", os.environ["JAVA_PROBE_CLASSPATH"], + "com.alipay.antchain.bridge.plugins.lib.transactions.CoordinatorProcessProbe", + "jdbc:mysql://127.0.0.1:18236/isn_test?allowPublicKeyRetrieval=true&useSSL=false", self.network] + processes = [subprocess.Popen(args + [prefix, "8", "query"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + for prefix in ["j1", "j2"]] + with concurrent.futures.ProcessPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(python_query_process, self.network, prefix) for prefix in ["p1", "p2"]] + values = [v for f in futures for v in f.result(timeout=60)] + for process in processes: + stdout, stderr = process.communicate(timeout=60) + self.assertEqual(0, process.returncode, stderr) + values.extend(stdout.splitlines()) + self.assertEqual(32, len(set(values))) if __name__ == "__main__": From 2ef453887ba65b3fb8e0903e886a9f68187171ad Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:19:43 +0800 Subject: [PATCH 13/16] docs: record deployed ISN acceptance and independent Ethereum proof blocker --- .../ACCEPTANCE_20260904.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md diff --git a/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md b/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md new file mode 100644 index 00000000..848f5b0a --- /dev/null +++ b/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md @@ -0,0 +1,63 @@ +# 2026-09-04 部署与验收记录 + +## 结论 + +Dioxide ISN 修复已部署;**完整验收矩阵尚未全部通过**,独立的 Ethereum/PTC 日志索引错误 +阻塞了普通突发测试中的 93 条源消息。没有重发这些消息、强制 PROVED 或绕过 PTC。 + +## 本轮已通过 + +- Mychain→Dioxide 监管:三批各 32 条,加额外跨插件竞争 32 条,共 128 条全部闭环, + 每条业务正文只执行一次;PTC 100%、监管四阶段完整。 +- Mychain 冒烟一条;普通 Ethereum 入站三条;Dioxide 普通/监管反向各一条。 +- 两插件交错分配:ISN 306=diox11、307=diox04、308=diox11、309=diox04。 +- 生产协调库总共 134 个 ISN(181–314)全部唯一、FINALIZED,无 ISN abort/UNKNOWN/FAILED。 +- Python 同一 operationId 再次调用复用原始哈希,数据库只有一条提交记录。 +- Ethereum→FISCO SDP V1/V2/V3 均成功,PTC 100%、监管四阶段完整。 +- 两条 Dioxide 锚定及现有 Ethereum/FISCO/Mychain 锚定 RUNNING。Portal、Runner、Data API、 + Mychain 查询适配器和三条隧道 active;公网 Overview/Statistics/UCP/Dioxide 交易查询 HTTP 200。 + +监管突发 batch1/2/3 源链整批耗时 1202/758/823ms;目标首条分配至最后 journal FINALIZED +为 38.19/34.38/449.69 秒。第三批包含回执查询修正和插件切换,不能计为正常稳态性能; +链上业务已经执行一次,仅恢复正常查询归档,没有重发。额外竞争批次目标阶段 40.07 秒。 + +离线测试:Java 8 协调 8 项,Python 10 项,两插件各 14 项,Runner 4 项;通用上游插件 7 项。 +包括两 Java/两 Python 的 128 次并发分配、签名前后退出恢复,以及跨语言查询 mailbox 32 次隔离。 + +## 代表 UCP + +- 冒烟:`909f29f4f0d434bbae533c5f73b73c89170b2e2983989fe811df1ac7c34ad0eb` +- 普通反向:`45982472456ccd640eed4a64fe412755c6844a69a47eeefa3e93f2d085baa794` +- 监管反向:`65863787379ca926db3e828bca469b708d0005638db11284668eb0b52280ce93` +- Ethereum→FISCO V1:`e832f98a3bffc4c865c8ee31e44deea51d3b08faa16c3e7071da46c8f52aad18` +- Ethereum→FISCO V2:`e289e5876e07635ceed32c07167c139173a6539ca1ef926815f0d8120dd31fc6` +- Ethereum→FISCO V3:`d3976ed57a162a3c18cd7f54612ab3be115f1e567cb1d6ad7120e2e0c828f175` + +## 独立阻塞与边界 + +普通 Ethereum 三批源交易均成功,但每个区块仅首条通过 PTC。第二条代表 UCP +`2fc32b4024cc8ab23959d321a6d16c1647d21033a97a70307c88162f595126cb` 的存储 +ledgerData.logIndex=2;它的实际 receipt 只有两个日志,合法局部下标 0/1、区块全局索引 2/3。 +Ethereum2/Ethereum3 采集端使用区块级 logIndex,HCDVS 却按 receipt 局部索引读取。 +93 条尚未进入 Dioxide,不能归因为 ISN,也不能标为通过。扩展采集插件/PTC 验证器的修复 +和部署范围已请求用户确认,当前不修改其证明或状态。 + +my02 现有测试应用处于全局监管模式,未为测试关闭其他业务的监管。Dioxide 原生 SDP 是 V1; +AppContractV2 是业务存储版本,不是 SDP V2。普通和 V2/V3 的替代测试矩阵待用户确认。 + +历史失败 UCP `ea0e456b…23fdf5` 不补发;旧 AuthMsg lambda9 异常另列诊断,不混入本次成功率。 + +## 上线版本 + +- Relayer SHA256 `13fef62428f9ab6c48cae5749a5404e69cb52bd85fefee7fd66b9cbadbaf470f` +- Plugin Server SHA256 `0118f771dbfba9ca9e5cbea9a57ae9a24aa9d6b52157e50d22a207effab78e37` +- dioxide(源码96adb49)SHA256 `11f0c6c5a649cbbc2789e8aa600ab965a41b29bba458d2b340d2573792ccb7ad` +- dioxide2(源码96adb49)SHA256 `3c6b6a6e679e9f06fe82792d3946bd7fc1c8a01c68c76343fec927a83e15aef3` +- Python coordinator `c9849d0`,crosschain adapters `0eec213`、部署助手 `68355fe`。 + +链上合约、账户/CID、公网端口和前端均未改变。备份包和协调记录保留;回滚不得删除签名记录 +或回退 ISN。Runner 停止会连带停止 Backend,恢复必须显式启动两者。 + +PR:[ICT #6](https://github.com/ICT-BCLab/AntChainBridge/pull/6)、 +[通用上游 #83](https://github.com/AntChainOpenLabs/AntChainBridge/pull/83)、 +[Runner #3](https://github.com/ICT-BCLab/crosschain/pull/3)。均 Draft,不 force-push、不合并。 From 0f0fbd65af9edc23a83a7a2e39c122b129f64683 Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:21:18 +0800 Subject: [PATCH 14/16] docs: confirm FISCO V1-V3 receiver business events --- acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md b/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md index 848f5b0a..5f9027e3 100644 --- a/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md +++ b/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md @@ -13,7 +13,8 @@ Dioxide ISN 修复已部署;**完整验收矩阵尚未全部通过**,独立 - 两插件交错分配:ISN 306=diox11、307=diox04、308=diox11、309=diox04。 - 生产协调库总共 134 个 ISN(181–314)全部唯一、FINALIZED,无 ISN abort/UNKNOWN/FAILED。 - Python 同一 operationId 再次调用复用原始哈希,数据库只有一条提交记录。 -- Ethereum→FISCO SDP V1/V2/V3 均成功,PTC 100%、监管四阶段完整。 +- Ethereum→FISCO SDP V1/V2/V3 均成功,PTC 100%、监管四阶段完整;通过独立只读 SDK 核对 + 三个 receipt 的接收合约事件,业务正文逐字节一致、各出现一次,目标状态为0(FISCO成功码)。 - 两条 Dioxide 锚定及现有 Ethereum/FISCO/Mychain 锚定 RUNNING。Portal、Runner、Data API、 Mychain 查询适配器和三条隧道 active;公网 Overview/Statistics/UCP/Dioxide 交易查询 HTTP 200。 From 147c7a6f69b8814a6b2c170e467e7863871467eb Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:30:09 +0800 Subject: [PATCH 15/16] docs: record recovery of all 93 Ethereum-blocked ISN acceptance messages --- .../tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md b/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md index 5f9027e3..518a1cdb 100644 --- a/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md +++ b/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md @@ -34,14 +34,17 @@ Dioxide ISN 修复已部署;**完整验收矩阵尚未全部通过**,独立 - Ethereum→FISCO V2:`e289e5876e07635ceed32c07167c139173a6539ca1ef926815f0d8120dd31fc6` - Ethereum→FISCO V3:`d3976ed57a162a3c18cd7f54612ab3be115f1e567cb1d6ad7120e2e0c828f175` -## 独立阻塞与边界 +## 独立阻塞修复与边界 普通 Ethereum 三批源交易均成功,但每个区块仅首条通过 PTC。第二条代表 UCP `2fc32b4024cc8ab23959d321a6d16c1647d21033a97a70307c88162f595126cb` 的存储 ledgerData.logIndex=2;它的实际 receipt 只有两个日志,合法局部下标 0/1、区块全局索引 2/3。 Ethereum2/Ethereum3 采集端使用区块级 logIndex,HCDVS 却按 receipt 局部索引读取。 -93 条尚未进入 Dioxide,不能归因为 ISN,也不能标为通过。扩展采集插件/PTC 验证器的修复 -和部署范围已请求用户确认,当前不修改其证明或状态。 +用户随后授权扩展Ethereum采集/PTC修复,独立提交0840fbd已部署,见ICT PR #7/通用上游PR #84。 +截至2026-09-04 21:29,原93条已正常取得背书并在Dioxide完成,三批共96/96成功;96条原raw_message指纹全部未变。 +原93条分配93个唯一ISN,分配窗口48.075秒,首条分配至最后FINALIZED为73.377秒。 +协调库此时227条/227个唯一ISN(181–407),无未决或失败项;监管96条和跨插件竞争32条亦重新回归通过。 +没有重发源交易、改写UCP证明或强制PROVED。新增采集普通/监管各2条同块消息正等待源链最终确定,另行记录结果。 my02 现有测试应用处于全局监管模式,未为测试关闭其他业务的监管。Dioxide 原生 SDP 是 V1; AppContractV2 是业务存储版本,不是 SDP V2。普通和 V2/V3 的替代测试矩阵待用户确认。 From 01f519518cb607154342efbd3c27f8c2a2d1bd02 Mon Sep 17 00:00:00 2001 From: 0xstride <73103011+fengjy73@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:50:43 +0800 Subject: [PATCH 16/16] docs: finalize ISN acceptance after Ethereum PTC repair --- .../dioxide-tx-coordinator/ACCEPTANCE_20260904.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md b/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md index 518a1cdb..56b3f689 100644 --- a/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md +++ b/acb-sdk/tools/dioxide-tx-coordinator/ACCEPTANCE_20260904.md @@ -2,19 +2,21 @@ ## 结论 -Dioxide ISN 修复已部署;**完整验收矩阵尚未全部通过**,独立的 Ethereum/PTC 日志索引错误 -阻塞了普通突发测试中的 93 条源消息。没有重发这些消息、强制 PROVED 或绕过 PTC。 +Dioxide ISN 修复及后续获授权的 Ethereum/PTC 日志索引修复均已部署。 +已执行的普通96条、监管96条、竞争32条及新增采集4条全部成功,原93条阻塞已解除。 +没有重发这些消息、强制 PROVED 或绕过 PTC。未执行的Mychain普通/Dioxide原生V2/V3组合另列边界,不计为通过。 ## 本轮已通过 - Mychain→Dioxide 监管:三批各 32 条,加额外跨插件竞争 32 条,共 128 条全部闭环, 每条业务正文只执行一次;PTC 100%、监管四阶段完整。 -- Mychain 冒烟一条;普通 Ethereum 入站三条;Dioxide 普通/监管反向各一条。 +- Mychain 冒烟一条;普通 Ethereum 三批共96条及新增同块2条;Dioxide 普通/监管反向各一条。 - 两插件交错分配:ISN 306=diox11、307=diox04、308=diox11、309=diox04。 -- 生产协调库总共 134 个 ISN(181–314)全部唯一、FINALIZED,无 ISN abort/UNKNOWN/FAILED。 +- 生产协调库最终229个ISN(181–409)全部唯一、FINALIZED,无ISN abort/UNKNOWN/FAILED。 - Python 同一 operationId 再次调用复用原始哈希,数据库只有一条提交记录。 - Ethereum→FISCO SDP V1/V2/V3 均成功,PTC 100%、监管四阶段完整;通过独立只读 SDK 核对 三个 receipt 的接收合约事件,业务正文逐字节一致、各出现一次,目标状态为0(FISCO成功码)。 +- 新增Ethereum→FISCO同块监管2条全部SUCCESS、PTC100%、监管四阶段完整;独立SDK确认正文各一次。 - 两条 Dioxide 锚定及现有 Ethereum/FISCO/Mychain 锚定 RUNNING。Portal、Runner、Data API、 Mychain 查询适配器和三条隧道 active;公网 Overview/Statistics/UCP/Dioxide 交易查询 HTTP 200。 @@ -44,7 +46,10 @@ Ethereum2/Ethereum3 采集端使用区块级 logIndex,HCDVS 却按 receipt 局 截至2026-09-04 21:29,原93条已正常取得背书并在Dioxide完成,三批共96/96成功;96条原raw_message指纹全部未变。 原93条分配93个唯一ISN,分配窗口48.075秒,首条分配至最后FINALIZED为73.377秒。 协调库此时227条/227个唯一ISN(181–407),无未决或失败项;监管96条和跨插件竞争32条亦重新回归通过。 -没有重发源交易、改写UCP证明或强制PROVED。新增采集普通/监管各2条同块消息正等待源链最终确定,另行记录结果。 +没有重发源交易、改写UCP证明或强制PROVED。新增采集普通/监管各2条同块消息也全部完成: +第二条普通消息RPC全局索引2、新局部索引0;第二条监管消息全局索引4、新局部索引1,PTC均100%。 +普通目标正文各一次;监管目标通过独立FISCO SDK确认receipt状态0、解封装正文精确一致且各一次。 +最终协调库229条/229个唯一ISN(181–409)全部FINALIZED。未改变Ethereum最终性策略。 my02 现有测试应用处于全局监管模式,未为测试关闭其他业务的监管。Dioxide 原生 SDP 是 V1; AppContractV2 是业务存储版本,不是 SDP V2。普通和 V2/V3 的替代测试矩阵待用户确认。