@openzeppelin/tronbox-upgrades
v0.1.1
Published
OpenZeppelin Upgrades plugin for TronBox: deploy and upgrade proxies on TRON with upgrade-safety validations.
Downloads
321
Readme
OpenZeppelin TronBox Upgrades
TronBox plugin for deploying and upgrading upgradeable smart contracts on
TRON, with the upgrade-safety checks of
@openzeppelin/upgrades-core.
Deploy transparent, UUPS and beacon proxies from your TronBox migrations, upgrade them with storage-layout validation, and keep a network file that records what is deployed where.
Installation
npm install --save-dev @openzeppelin/tronbox-upgradesThe plugin declares tronbox (>= 4.8.0), tronweb (>= 6.0.0) and ethers
(^6.13.0) as peer dependencies. The TronBox floor is real, for two reasons a
user can check: 4.7.0 introduced the build_info_directory records this
plugin's validation reads (older releases never write them, so every operation
refuses), and 4.8.0 fixed migrate exiting the process or silently
continuing on errors thrown by migration scripts, contract calls, or
deployer steps (TronBox's own 4.8.0 changelog) — the refusals this plugin
raises mid-migration depend on that fix to be reported as a failed migration.
One-time setup: make the proxy contracts compilable
The plugin deploys proxy contracts by artifact name, and TronBox only compiles
what your own sources import. Import the plugin's contract file once, from any
.sol file in your contracts/ directory:
import "@openzeppelin/tronbox-upgrades/contracts/Proxies.sol";That single import puts TransparentUpgradeableProxy, ProxyAdmin,
TRC1967Proxy, UpgradeableBeacon and BeaconProxy into your build output.
Without it, deploy operations refuse with a message naming this file as the
remedy.
Quickstart
An upgradeable Box, deployed behind a proxy and then upgraded in place.
contracts/Box.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Box {
uint256 private _value;
bool private _initialized;
function initialize(uint256 value_) public {
require(!_initialized, "already initialized");
_initialized = true;
_value = value_;
}
function value() public view returns (uint256) {
return _value;
}
}contracts/BoxV2.sol — same storage, one new function:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract BoxV2 {
uint256 private _value;
bool private _initialized;
function initialize(uint256 value_) public {
require(!_initialized, "already initialized");
_initialized = true;
_value = value_;
}
function value() public view returns (uint256) {
return _value;
}
function increment() public {
_value += 1;
}
}migrations/1_deploy_and_upgrade.js:
const {
deployProxy,
upgradeProxy,
} = require('@openzeppelin/tronbox-upgrades');
const Box = artifacts.require('Box');
const BoxV2 = artifacts.require('BoxV2');
module.exports = async function (deployer) {
// The migration sandbox's own handles, passed through: artifacts, tronWrap
// and waitForTransactionReceipt are file-scope globals TronBox provides.
const handles = { deployer, artifacts, tronWrap, waitForTransactionReceipt };
const deployed = await deployProxy(Box, [42], handles);
console.log('deployProxy address:', deployed.address);
console.log('deployProxy tx:', deployed.transaction.hash);
const upgraded = await upgradeProxy(deployed.address, BoxV2, handles);
console.log('upgradeProxy implementation:', upgraded.implementation);
console.log('upgradeProxy tx:', upgraded.transaction.hash);
// The upgraded contract instance rides on the result:
console.log('value:', (await upgraded.contract.value()).toString());
};Two things to notice:
- The handles object. TronBox gives every migration file
deployeras a parameter andartifacts,tronWrapandwaitForTransactionReceiptas file-scope globals. The plugin's operations take them as their last argument — build the object once at the top of the migration and pass it to every call. await, always. Operations started from one migration body run one at a time, in call order, even withoutawait— but only yourawaithears the outcome. An operation that is not awaited cannot report its failure to your migration: the failure surfaces as an unhandled rejection, which crashestronbox migratewith a non-zero exit instead of letting the run end green. When you do await — the only supported usage — a failure rejects yourawaitwith the real error, where you can handle it.
tronbox-config.js, here against a local
TronBox Runtime Environment
node (the private key below is the TRE quickstart's well-known development
key — never put a real key in this file; use an environment variable):
module.exports = {
networks: {
development: {
privateKey: 'c8afe0306dbb962a4ce8c09954f050c57facf05eb7ac88497ee1489d741aaff1',
userFeePercentage: 100,
feeLimit: 1000 * 1e6,
fullHost: 'http://127.0.0.1:9090',
network_id: '9'
}
},
compilers: { solc: { version: '0.8.26' } }
};Run it:
tronbox migrate --network developmentdeployProxy validates Box for upgrade safety, deploys the implementation
and a proxy pointing at it, calls initialize(42) through the proxy, waits
for the transaction to confirm and checks the receipt's verdict, then
records the deployment. upgradeProxy validates BoxV2 against the storage
layout recorded for the running implementation, deploys the new
implementation, switches the proxy, and verifies on-chain that the switch
actually happened. After the run, value() through the proxy answers 42 —
same address, new code.
The deployment record lands in .openzeppelin/ at your project root, keyed by
the chain's real identity (not the config's network_id). Commit it for the
networks you share; it is what upgrade validation reads.
API
All operations take the migration handles object as their final argument
(for the validate pair, { artifacts } alone is enough, so they also work in
tronbox test files and CI).
| Operation | What it does |
|---|---|
| deployProxy(Contract, args?, opts) | Validate, deploy implementation + proxy (kind: 'uups'/'transparent' if given; inferred from the implementation — transparent only when no UUPS entry point is found — if omitted), initialize, record. |
| upgradeProxy(proxyAddress, Contract, opts) | Validate against the recorded layout, deploy the new implementation, switch the proxy, verify. |
| deployBeacon(Contract, opts) | Deploy an upgradeable beacon pointing at the implementation. |
| deployBeaconProxy(beaconAddress, Contract, args?, opts) | Deploy a proxy reading its implementation from the beacon. |
| upgradeBeacon(beaconAddress, Contract, opts) | Point an existing beacon at a new, validated implementation. |
| validateImplementation(Contract, opts) | Upgrade-safety checks only; touches no chain state. |
| validateUpgrade(FromContract, ToContract, opts) | Storage-layout compatibility between two contracts; touches no chain state. |
| deployImplementation(Contract, opts) | Deploy (or reuse) the implementation alone; the proxy layer is untouched. |
| prepareUpgrade(proxyAddress, Contract, opts) | Validate against the proxy's current implementation and deploy the new one — the switch stays a later governance action. |
| forceImport(proxyAddress, Contract, opts) | Adopt an existing proxy (or beacon) into the deployment record, verifying on-chain bytecode against the artifact. |
| transferProxyAdminOwnership(proxyAddress, newOwner, opts) | Transfer ownership of a transparent proxy's ProxyAdmin, with a pre-read that turns a repeat into a declared no-op. |
The ERC-1967 and beacon readers below return TRON base58 addresses; convert
explicitly (TronWeb.utils.address.toHex) if you need the hex form.
Operation results carry their addresses tool-verbatim — whatever form the
host reported — so compare addresses by identity (canonicalize both sides),
never by spelling.
Two more exports round out the public surface, both cheap — no validation, no record, no spend:
| Export | What it does |
|---|---|
| erc1967.getImplementationAddress/getAdminAddress/getBeaconAddress(address, opts) | Read one of the three standard ERC-1967 proxy slots directly from chain, as base58. |
| beacon.getImplementationAddress(beaconAddress, opts) | Read a beacon's own implementation(), as base58. |
| silenceWarnings() | Suppress this plugin's advisory writes for the life of the process. Advisories only: a thrown error is never suppressed, and every reduced-fidelity statement still reaches the result's notes. |
Options follow the
OpenZeppelin Upgrades API
where the concepts coincide: kind, initializer, constructorArgs,
unsafeAllow, redeployImplementation, timeout, pollingInterval, and the
rest. An option an operation does not accept is refused by name, never
silently ignored — the beacon operations accept no kind (a beacon proxy has
exactly one kind), and there is no txOverrides (fee behavior is configured
in tronbox-config.js). An allowance expressed through two channels that can
disagree (unsafeAllow values alongside their boolean twins, or a truthy
useDeployedImplementation alongside redeployImplementation) is refused
with OptionConflictError, naming which one to drop. timeout and
pollingInterval bound one wait and only one — the upgrades engine's wait
for an implementation deploy to be mined; on operations that deploy no
implementation (deployBeaconProxy, ownership transfer) they resolve without
changing anything, and no operation's own confirmation wait reads them.
Always pass the argument list — an array, or [] — before any options
object: deployProxy(Contract, [42], opts), never deployProxy(Contract,
opts); the latter is refused by name before anything spends
(OptionsInArgsPositionError). initializer: false is likewise refused
(EmptyInitializerRefusedError): the ported proxy contracts revert on empty
initialization data, so every proxy kind — beacon included — initializes in
the deploy transaction; give the contract an initializer it answers.
upgradeProxy's call option encodes exactly what you spell out. A
string starting with 0x is sent as raw calldata, verbatim. A bare function
name (call: 'reinitialize') encodes that function with zero arguments,
and an overloaded name is refused by ethers as ambiguous whatever the
arity. To pass arguments, or to disambiguate an overload, use the full
signature in the object form: call: { fn: 'reinitialize(uint8)', args: [1]
}. And upgradeProxy always dispatches the upgrade transaction — even when
the proxy already runs the target implementation — so a call executes on
every invocation: re-running a migration whose call targets a one-time
reinitializer reverts on-chain (TransactionRevertedError), not as a quiet
skip.
The types say exactly what each operation accepts. An operation's options
parameter is its own exported alias intersected with MigrationHandles (the
five migration handles), so deployProxy's final argument is
DeployProxyOptions & MigrationHandles, and a key that operation does not
accept is a compile error at the call site — where it previously
type-checked and only failed at runtime, because the parameter was an open bag
with a string index signature.
That compile error is TypeScript's excess-property check, so it applies to an object literal written at the call, which is how a migration normally passes options. Hoist the options into a variable first and the extra key becomes structurally invisible to the compiler:
const opts = { ...handles, totallyMadeUpKey: 1 };
await deployProxy(Box, [42], opts); // compiles; refused at runtimeWrite satisfies DeployProxyOptions & MigrationHandles on that variable to get
the check back. Either way the runtime refusal (UnknownOptionError) stands —
it is what covers JavaScript callers and anything that reaches an operation
untyped. Each alias's members are checked against its operation's own
accepted-options list in both directions, and each signature against its own
alias including member types (test/public-option-surface.test.ts), so a key
added to one side and not the other is a build failure rather than a published
lie.
The refusals you can cause — and fix — are exported classes, so a catch
can branch on instanceof instead of matching a message: each operation
family's own refusals (a bad option value, a missing owner, an unsupported
kind, and their siblings), the option family (UpgradesOptionError plus
UnknownOptionError, OptionValueError, OptionConflictError), the
environment family's base (TronBoxEnvironmentError),
ValidationInputRefusedError, and the record-recovery refusals a caller
branches on to act — "this is a different chain" versus "the record's
fingerprint file is unusable" (ChainInstanceChangedError,
RecordFingerprintUnreadableError), plus RecordUnreadableError (the record
file itself cannot be read — not JSON, or contents the engine refuses) and
RecordLockedError (another run holds the record's lock; the engine has
already retried, so it is a real race).
Many of the remaining errors — a node outage, a malformed reply, an
environment missing one handle, an error raised while a result is built —
carry a stable code string, and branching on code is the documented
path for those. The rest, including the engine's own verdicts ("is not
upgrade-safe", "not storage-compatible"), carry only their message. The class
surface is deliberately small: an exported class can
never be renamed once published, while adding one later is safe — and for a
migration tool, most errors reach a human reading tronbox migrate output,
where the message is the surface that matters. ResultCapabilityUnavailableError
(raised from a returned result's own accessor, on a member this plugin cannot
support) stays unexported under the same rule and carries its code.
No refusal after a confirmed on-chain write withholds the address. Once a
transaction has landed, the plugin can still refuse — the effective sender may
disagree with the account the authority check ran against, or writing the
deployment record may fail — and a refusal at that point has to be actionable,
because the contract exists and you are paying for it either way. Every such
refusal names the address and the transaction, and points at
forceImport(address), which is the tool that teaches the record about a
contract that already exists. SenderMismatchError,
ConfirmationIndeterminateError and ProxyRecordWriteFailedError all carry the
pair; the last keeps the underlying record-layer error as its cause, so you can
still branch on RecordLockedError versus RecordUnreadableError while knowing
which proxy is unrecorded. The one refusal that names no address is
TransactionRevertedError, and that is the point: a mined revert deployed
nothing.
A failed operation is safe to re-run. Whatever step failed, your project
is left in a state where running the migration again is the right move, and
each refusal's message says what to do first when there is anything to do. The
one case needing care is a transaction whose outcome the node never reported:
the plugin cannot tell whether it landed, so ConfirmationIndeterminateError
says so, names that transaction to check on the chain, and
forceImport(address) adopts the deployment if it did land.
constructorArgs cannot end in a plain object or null. TronBox's own
contract layer treats a trailing non-array object — and null, since
typeof null is also "object" — as its own energy-parameter slot, never as
part of your constructor call: it pops that argument off the list entirely
and mines it for the deploy parameters it recognizes (fee limit, origin
energy limit, and the like), so your constructor never receives it — usually
as a loud arity mismatch, but silently exactly when the real constructor also
expects that many arguments once the struct is gone. A trailing null fares
no better: depending on the installed TronBox version it either crashes
before any deploy is attempted, or reaches the ABI encoder, which typically
refuses it too. Every operation that deploys a contract with your
constructorArgs (deployProxy, upgradeProxy, deployBeacon,
upgradeBeacon, deployImplementation, prepareUpgrade) refuses either
shape by name before attempting any deploy. If your constructor's last
parameter is genuinely a struct, wrap it so it is not the final argument —
pass it as an array member, or add a trailing dummy argument — or restructure
the constructor. deployBeacon also refuses up front if it cannot derive an
owner for the beacon (no initialOwner given and no sending account
configured) — pass initialOwner or configure a from address. deployProxy
refuses the same state for a transparent proxy's admin.
Proxy provenance
Every proxy contract this plugin deploys — TransparentUpgradeableProxy, ProxyAdmin, TRC1967Proxy (the UUPS proxy), UpgradeableBeacon and BeaconProxy — comes from one package, @openzeppelin/tron-contracts, and reaches your build through one file, bundled with this plugin:
import "@openzeppelin/tronbox-upgrades/contracts/Proxies.sol";The plugin declares @openzeppelin/tron-contracts as a regular dependency,
so installing the plugin brings the proxy sources into your node_modules
with no extra step.
TronBox resolves compiled artifacts by bare contract name, with no
directory or package qualification. That means a proxy artifact this plugin
needs is exactly as available as any other contract in your build — and
exactly as exposed to a name collision: if your own project (or another
dependency) also compiles a contract named, say, ProxyAdmin, TronBox's index
cannot tell them apart. Deployment does not guess:
- Missing artifact (the import above was never added): refused by name
(
ProxyArtifactMissingError), naming the one-file remedy. - Name collision (more than one compiled contract answers to the bare
name): refused by name (
ProxyArtifactCollisionError), listing every colliding source path. Nothing is picked by file-order chance.
Rename or remove the colliding contract, or keep the proxy contracts scoped to the single import file above and nowhere else in your sources.
Validation without storage layouts
Upgrade-safety validation reads the build record TronBox already wrote —
the <hash>.output.json / <hash>.json pair under your build-info directory
— after verifying by content that it describes the compiled artifact. The
plugin never runs a compiler of its own; this is the same model Foundry's
upgrades plugin uses.
TronBox does not ask solc for storage layouts, so the record carries none and the layout is reconstructed from the record's own AST. That comparison works by name, type and declaration order, without slot positions, and it is stricter than it may sound:
- Still detected and refused: renaming a variable, changing its type, reordering variables, and deleting one.
- Still accepted: appending new variables at the end, which is the safe upgrade shape — appends are structurally exempt from position questions.
- Cannot be decided, so refused conservatively: the two shapes that need
slot arithmetic — shrinking a
__gaparray to absorb a new variable, and repacking variables inside a slot's existing padding. Both are safe when done correctly, but without positions the check cannot verify the arithmetic, and by default — absent an explicit unsafe override such asunsafeSkipStorageCheck: true— this plugin never accepts what it cannot verify. If you use either pattern, expect a refusal and restructure the change as an append.
When the build record for a contract is absent (never compiled here, or a cleaned build directory) or stale (it no longer matches the compiled artifact), validation refuses and names the remedy:
tronbox compile --allThe --all flag forces recompilation of unchanged sources, so the remedy
works for any compilable concrete contract — though not for an abstract
contract or interface, since recompilation cannot manufacture deployed
bytecode where none can exist. For anything deployable, a fresh build record
and artifact are written together even when TronBox considers the project up
to date.
When TronBox can emit storageLayout into its build records, the same
record read will carry real slot positions and the two undecidable shapes
above become decidable in principle — but this plugin does not pick that up
for free. Its own invariant guard (ValidationInputInvariantError) refuses
loudly the day a build record reports anything but the declaration-order-only
fidelity it assumes today, so the benefit lands only once this plugin itself
is updated to read the new fidelity, not merely once TronBox emits it.
Known limitation: custom storage layouts
Solidity 0.8.29's layout at custom storage location is not seen by the
storage-safety validation: the layout comparison upstream drops the base slot,
so a changed layout at value would pass validation while relocating every
variable in the contract
(OpenZeppelin/openzeppelin-upgrades#1296).
In practice TronBox's compiler ceiling (solc 0.8.26 for TVM) predates the
syntax, so affected contracts cannot currently be compiled for TRON at all —
but if you validate artifacts compiled elsewhere, do not rely on the check
catching a layout at change.
License
MIT
