Skip to content

fix(core): harden ApiWrapper input validation and channel shutdown - #222

Open
0xbigapple wants to merge 8 commits into
tronprotocol:release_1.0.0from
0xbigapple:fix/apiwrapper-hardening
Open

fix(core): harden ApiWrapper input validation and channel shutdown#222
0xbigapple wants to merge 8 commits into
tronprotocol:release_1.0.0from
0xbigapple:fix/apiwrapper-hardening

Conversation

@0xbigapple

@0xbigapple 0xbigapple commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

  • withTLS(File): reject directories and unreadable cert files at configuration time
  • ApiWrapperBuilder.toString(): print interceptor count instead of expanding
    interceptor instances
  • close(): shut down both channels, await termination up to 5s each, then
    shutdownNow()
  • parseAddress: validate non-empty input as a 21-byte 0x41 address and reject
    input longer than 64 characters before decoding; decoding failures surface as the
    documented IllegalArgumentException. An empty string maps to ByteString.EMPTY
    (unset protobuf field), keeping optional-address
  • signTransaction: reject empty transaction raw data; a TransactionExtention
    must carry a txid that matches the raw data
  • SECP256K1.PrivateKey.create / withPrivateKey: reject private keys outside
    [1, n - 1]
  • SECP256K1: pass the Bouncy Castle provider instance explicitly to the SDK's own
    JCA calls and leave the JVM-global provider list untouched; Android's stripped
    built-in "BC" is still bypassed

Why are these changes required?

This PR has been tested by:

  • Unit Tests

Extra details
Breaking changes:

  • The entry points above now throw IllegalArgumentException on invalid input.
    In particular, a client built with an out-of-range placeholder private key
    (e.g. all-zero) could previously still perform on-chain queries; it now fails
    at construction — build a read-only client without a private key instead.

- withTLS(File) now rejects directories and unreadable files with a clear
  IllegalArgumentException at configuration time, instead of failing later
  in build() with a generic "Failed to create TLS channel"
- toString() no longer expands customInterceptors (whose own toString may
  contain tokens or other sensitive data); it prints customInterceptorCount
  instead
close() previously only initiated an async shutdown and returned, so RPCs
issued without a deadline could keep the channel alive indefinitely. Now it
waits up to 5 seconds per channel for graceful termination, then falls back
to shutdownNow(), restoring the interrupt flag if interrupted while waiting.
parseAddress previously returned whatever bytes the hex/base58 input decoded
to, so inputs like "41", "0x41" or 22-byte hex strings produced malformed
addresses that could even end up in SUCCESS transactions under local-create
mode. The decoded bytes are now validated to be a proper TRON address
(21 bytes with the 0x41 prefix) and rejected otherwise with a clear
IllegalArgumentException.

freezeBalance/unfreezeBalance treat the receiver as the optional field it is
on chain: an empty receiveAddress (used by the no-receiver overloads, meaning
the owner's own stake) skips setReceiverAddress instead of being passed
through parseAddress, which would now reject it; non-empty receivers are
still validated.
signTransaction(TransactionExtention, KeyPair) previously signed whatever
txid the extention carried, so a forged txid (e.g. transaction A's txid
attached to transaction B) yielded a valid signature for a different
transaction. The hash to sign is now always recomputed from the embedded
transaction's raw data; a non-empty txid that does not match the raw data
is rejected with IllegalArgumentException, and an absent txid no longer
results in signing empty bytes.
SECP256K1.PrivateKey.create previously accepted any 32-byte scalar, so 0,
the curve order n, and n + 1 all produced usable-looking KeyPairs that could
derive addresses (n + 1 even deriving the same address as private key 1 via
the mod-n reduction in PublicKey.create) but failed later at signing time
inside Bouncy Castle. The scalar range is now validated at construction with
the same [1, n - 1] rule Bouncy Castle enforces, covering KeyPair(String) and
all other construction paths. ApiWrapperBuilder.withPrivateKey performs the
same check up front so invalid keys fail at input time rather than build().
parseAddress maps an empty string to ByteString.EMPTY (an unset protobuf
field, the pre-1.0 behavior for optional addresses such as a freeze
receiver or a constant-call owner), rejects input longer than 64 characters
before decoding, and rethrows decoding failures as the documented
IllegalArgumentException. The freeze/unfreeze receiver guards are reverted
accordingly.

signTransaction now rejects empty transaction raw data (previously signed
silently, swallowing node errors) and, for TransactionExtention, requires a
txid that is present and matches the raw data.
Loading SECP256K1 used to remove the host's "BC" provider and insert the
SDK's own instance at priority 1, silently changing algorithm resolution and
compliance posture for the whole process. The provider is now passed
explicitly to KeyPairGenerator and the JVM-global provider list is no longer
touched; the bundled full provider still covers Android's stripped "BC".
Applications that implicitly relied on trident registering BC globally must
now register it themselves.

The hand-written [1, n - 1] scalar check is replaced with Bouncy Castle's
own ECDomainParameters.validatePrivateScalar, and withPrivateKey wraps
validation failures as "invalid hexPrivateKey (reason)" without echoing
key material.
@0xbigapple
0xbigapple requested a review from 317787106 July 29, 2026 04:33
… complete close()

- deprecate SECP256K1.PROVIDER: trident no longer registers Bouncy Castle
  in the JVM-global provider list, so a provider named "BC" is not
  guaranteed to exist; callers that need it must register it themselves
- tighten parseAddress MAX_ADDRESS_LENGTH from 64 to 44, the largest legal
  address form (base58: 34, hex: 42, 0x-hex: 44), so a 64-char hex private
  key passed by mistake is rejected by the length gate instead of being
  echoed into the exception message
- close(): awaitTermination() called shutdownNow() on timeout and returned
  immediately, so close() could return with the channel still not
  terminated; it now waits again for the forced cancellation to take
  effect
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant