Skip to content

Devnet tx submission throws IllegalArgumentException from upstream tx-forwarding after the transaction was already accepted and applied #55

Description

@beanbot-gh

Summary

On a standalone devnet (no upstream peer configured), submitTransaction() throws
IllegalArgumentException: port must be between 1 and 65535after the transaction has
already been admitted to the mempool, included in a block, and applied to ledger state.

The ledger outcome is correct. The API contract is not: the caller gets an exception for a
transaction that actually succeeded.

This affects the in-process JVM path (yano-testkit / YanoDevnetTestKit, and any embedder
assembling a devnet from YanoConfig.devnetDefault(...)). The packaged app binary is not
affected — see Why the app binary is unaffected.

Impact

A false failure is worse than a clean failure here. A caller that sees an exception will
reasonably retry or surface an error to the user, while the original transaction is already
on chain. For the testkit specifically it means the documented "submit a transaction against
a devnet" flow does not work out of the box.

Reproduction

Minimal JUnit test using only published 0.1.0-pre12 artifacts and default devnet config:

class SubmitProbeTest {

    @RegisterExtension
    static YanoDevnetExtension yano = YanoDevnetExtension.devnet()
            .startNode()
            .blockTimeMillis(200)
            .epochLength(60);

    @Test
    void submitThrowsButTxLands(YanoDevnetTestKit kit) throws Exception {
        kit.await().untilReady();
        kit.await().untilBlockAtLeast(1);

        var alice = kit.wallets().newWallet(0);
        var bob   = kit.wallets().newWallet(1);
        kit.faucet().fund(alice, 1_000_000_000L);

        BackendService backend = YanoBackendService.from(kit);
        var signedTx = new QuickTxBuilder(backend)
                .compose(new Tx().payToAddress(bob.address(), Amount.ada(250)).from(alice.address()))
                .withSigner(SignerProviders.signerFrom(alice.account()))
                .buildAndSign();

        byte[] cbor = signedTx.serialize();
        String expectedHash = TransactionUtil.getTxHash(cbor);

        boolean threw = false;
        try {
            kit.transactions().submit(cbor);
        } catch (RuntimeException e) {
            threw = true;
            System.out.println("submit() THREW: " + e);
        }

        boolean landed;
        try {
            kit.await().untilTxVisible(expectedHash);   // poll the UTXO surface
            landed = true;
        } catch (AssertionError e) {
            landed = false;
        }

        System.out.println("threw=" + threw
                + " landed=" + landed
                + " bob=" + kit.assertions().wallet(bob).balance());
    }
}

Note: reproducing this test needs yano.epoch-params.tracking-enabled=false so that
QuickTxBuilder can fetch protocol params — that is a separate issue, filed alongside this one.

Observed output

expected txHash = 75f82077bec10dad66886b7990bca1f5c1351b16610150cce95f441d0c6b0206
submit() THREW: IllegalArgumentException: port must be between 1 and 65535
now polling for the tx's outputs for up to 15s ...
---------------------------------------------
submit() threw ..... : true
tx visible on chain  : true
bob balance (lovelace): 250000000
---------------------------------------------

The transfer was applied in full — bob received his 250 ADA — and the caller still got an
exception instead of the transaction hash.

Stack trace

java.lang.IllegalArgumentException: port must be between 1 and 65535
    at com.bloxbean.cardano.yano.p2p.peer.PeerEndpoint.<init>(PeerEndpoint.java:15)
    at com.bloxbean.cardano.yano.runtime.sync.SyncSubsystem.activeUpstreamPeer(SyncSubsystem.java:1753)
    at com.bloxbean.cardano.yano.runtime.sync.SyncSubsystem.submitTxBytes(SyncSubsystem.java:667)
    at com.bloxbean.cardano.yano.runtime.internal.RuntimeNode.lambda$submitTransaction$0(RuntimeNode.java:2324)
    at com.bloxbean.cardano.yano.runtime.tx.TxSubsystem.submitTransaction(TxSubsystem.java:264)
    at com.bloxbean.cardano.yano.runtime.internal.RuntimeNode.submitTransaction(RuntimeNode.java:2322)
    at com.bloxbean.cardano.yano.testkit.devnet.YanoTransactions.submit(YanoTransactions.java:33)
    at com.bloxbean.cardano.yano.testkit.devnet.YanoTransactions.submitAndAwait(YanoTransactions.java:44)

(line numbers from the published 0.1.0-pre12 jars; the same code path is present in current source)

Root cause

TxSubsystem.submitTransaction admits the transaction first, then invokes the upstream-gossip
callback — and lets that callback's exception escape:

public String submitTransaction(byte[] txCbor, BiConsumer<String, byte[]> acceptedSubmitter) {
    String txHash = admitTransaction(txCbor, "rest-api");   // mempool + block + ledger state
    if (acceptedSubmitter != null) {
        acceptedSubmitter.accept(txHash, txCbor);           // throws
    }
    return txHash;                                          // never reached
}

In SyncSubsystem.submitTxBytes the peer is resolved unconditionally, before the guard
that checks whether a peer session actually exists:

// SyncSubsystem.java:665
public void submitTxBytes(String txHash, byte[] txCbor, TxBodyType txBodyType) {
    String forwarding = normalizedTxForwarding();
    if ("disabled".equals(forwarding)) { ... return; }
    TxDiffusion txDiffusion = txDiffusion();
    PeerSession activeSession = peerSession;
    ConfiguredUpstreamPeer active = activeUpstreamPeer();   // <-- unconditional, throws here
    boolean allHot = "all-hot".equals(forwarding);
    boolean allHotTrusted = "all-hot-trusted".equals(forwarding);
    if (activeSession != null && activeSession.isRunning() && (...)) {
        ...
    }
}

With no configured upstream peers, activeUpstreamPeer() falls back to building an endpoint
from the legacy remote fields:

// SyncSubsystem.java:1757
private ConfiguredUpstreamPeer activeUpstreamPeer() {
    if (upstreamPeers.isEmpty()) {
        return new ConfiguredUpstreamPeer(
                "remote",
                new PeerEndpoint(remoteCardanoHost, remoteCardanoPort, protocolMagic),
                true, 0, "legacy-remote");
    }
    ...
}

PeerEndpoint is a record with validating invariants:

public record PeerEndpoint(String host, int port, long protocolMagic) {
    public PeerEndpoint {
        Objects.requireNonNull(host, "host");
        if (host.isBlank()) throw new IllegalArgumentException("host must not be blank");
        if (port <= 0 || port > 65_535) throw new IllegalArgumentException("port must be between 1 and 65535");
    }
}

YanoConfig.devnetDefault(...) intentionally leaves those fields unset:

.remoteHost(null)
.remotePort(0)
.enableClient(false)
.enableBlockProducer(true)

so the endpoint invariants can never be satisfied. Default normalizedTxForwarding() is
"active-selected", so the disabled early-return does not apply.

Two independent defects here:

  1. Upstream resolution runs when there is no upstream to resolve. A block-producing devnet
    with enableClient=false has nobody to gossip to.
  2. A gossip failure fails the whole submit. Upstream diffusion is best-effort and happens
    after acceptance; it should not turn an accepted transaction into a caller-visible error.

Why the app binary is unaffected

The packaged app's application.yml ships a preprod default (remoteHost: preprod-node.world.dev.cardano.org,
remotePort: 30000), so PeerEndpoint construction succeeds even in devnet mode. Confirmed via
GET /api/v1/devnet/config on a native devnet started by @bloxbean/yano-testkit:

{"protocolMagic":42,"clientEnabled":false,"devMode":true,
 "remoteHost":"preprod-node.world.dev.cardano.org","remotePort":30000, ...}

That is why the JS testkit submits transactions successfully with no workaround, and the JVM
testkit does not. It also means the JVM path is relying on an unrelated production default to
avoid a crash.

Suggested fix

Either or both:

  • In submitTxBytes, move activeUpstreamPeer() inside the branch that needs it, or skip
    forwarding entirely when upstreamPeers.isEmpty() and no valid legacy remote is configured.
  • Wrap the acceptedSubmitter callback in TxSubsystem.submitTransaction so upstream-diffusion
    failures are logged, not propagated — the transaction is already accepted at that point.

Optionally, default upstream.tx.forwarding to disabled when enableClient is false, since a
standalone producer has no upstream by definition.

Workaround

Set the forwarding policy on the YanoConfig POJO:

YanoConfig config = YanoConfig.devnetDefault(0);
config.setUpstream(UpstreamConfig.builder()
        .tx(UpstreamTxConfig.builder().forwarding("disabled").build())
        .build());

Note this must go on the config object — passing yano.upstream.tx.forwarding through
YanoDevnetTestConfig.Builder.runtimeOption(...) has no effect, because the runtime globals map
does not feed upstreamConfig.

Environment

  • Yano 0.1.0-pre12 (Maven Central), yano-testkit + yano-testkit-ccl
  • Cardano Client Lib 0.8.0-pre4
  • JDK 25, Gradle 9.4.1, macOS arm64
  • Devnet profile: protocol magic 42, protocol version 11, RocksDB temp storage

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions