@pushchain/core 6.0.21 → 6.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ @pushchain/core@6.0.23 (2026-09-01)
2
+
3
+ - docs: changelog entry for the EVM PC20 burn routing fix
4
+ - fix: route EVM PC20 imports through the payload path
5
+
6
+ ---
7
+
8
+ @pushchain/core@6.0.22 (2026-08-14)
9
+
10
+ - fix: make PC20 first exports and Solana R3 E2E reliable
11
+ - fix: support PC20 exports for standard ERC-20 tokens
12
+ - feat: curated E2E CI suite with pre-flight fund gate
13
+
14
+ ---
15
+
1
16
  @pushchain/core@6.0.21 (2026-08-01)
2
17
 
3
18
  - fix: pc20 bug fix
@@ -11,8 +26,15 @@
11
26
  @pushchain/core@6.1.0 (unreleased)
12
27
 
13
28
  - Added first-class PC20 support to the universal transaction API. PC20 tokens
14
- are resolved from their chain and address through UniversalCore, without an
15
- explicit token-standard flag.
29
+ are identified by chain and address without an explicit token-standard flag.
30
+ External wrappers are resolved through UniversalCore, while Push-native
31
+ tokens are validated directly on Push Chain.
32
+
33
+ - Removed the custom `pc20Metadata()` requirement for Push-native PC20 exports.
34
+ Any metadata-compatible ERC-20 born on Push Chain can now be exported: the SDK
35
+ reads the standard `name()`, `symbol()`, and `decimals()` functions, verifies
36
+ deployed bytecode, and continues to reject synthetic PRC20 addresses from the
37
+ PC20 path.
16
38
 
17
39
  - Added `PushChain.utils.tokens.getPC20Address()` for resolving either a
18
40
  Push-native PC20 or an external wrapper and listing its confirmed deployments.
@@ -40,10 +62,24 @@
40
62
  account as the gateway recipient instead of the zero address, preventing the
41
63
  forwarded funds leg from attempting to credit `address(0)`.
42
64
 
65
+ - Fixed first PC20 wrapper deployments running out of gas on the destination.
66
+ The SDK now re-quotes UGPC with the resolved deployment-aware limit and writes
67
+ the corresponding explicit limit into the outbound request.
68
+
43
69
  - Realigned inbound protobuf fields with the chain, exposed PC20 inbound
44
70
  metadata, and added SVM remaining-account support required by the Solana
45
71
  gateway.
46
72
 
73
+ - Fixed EVM PC20 wrapper burns destroying the wrapper without unlocking the
74
+ canonical token. A payload-less burn took the funds-only path, whose empty
75
+ payload gains the gateway's PC20 selector and then fails the chain's inbound
76
+ decode at ballot finalization — no handler runs, no revert is produced, and
77
+ the already-burned wrapper leaves the canonical token locked in VaultPC20.
78
+ The always-forward mitigation was gated to Solana; PC20 imports now route
79
+ through the payload path on both VMs, and `executeFundsOnly` fails closed
80
+ with `PC20UnsafeEmptyPayloadError` if a PC20 import ever reaches it again.
81
+ PRC20 and native funds-only flows are unchanged.
82
+
47
83
  ---
48
84
 
49
85
  @pushchain/core@6.0.20 (2026-07-10)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushchain/core",
3
- "version": "6.0.21",
3
+ "version": "6.0.23",
4
4
  "homepage": "https://push.org",
5
5
  "keywords": [
6
6
  "pushchain",
@@ -50,7 +50,11 @@
50
50
  }
51
51
  },
52
52
  "scripts": {
53
- "build:proto": "scripts/protoc-generate.sh"
53
+ "build:proto": "scripts/protoc-generate.sh",
54
+ "e2e:list": "ts-node --transpile-only __e2e__/ci/run.ts --list",
55
+ "e2e:verify": "ts-node --transpile-only __e2e__/ci/run.ts --verify",
56
+ "e2e:preflight": "ts-node --transpile-only __e2e__/ci/preflight.ts",
57
+ "e2e:ci": "ts-node --transpile-only __e2e__/ci/run.ts"
54
58
  },
55
59
  "type": "commonjs",
56
60
  "main": "./src/index.js",
@@ -67,7 +71,9 @@
67
71
  ],
68
72
  "devDependencies": {
69
73
  "@types/bs58": "^4.0.0",
74
+ "dotenv": "^16.4.5",
70
75
  "secp256k1": "^5.0.1",
76
+ "ts-node": "10.9.1",
71
77
  "ts-proto": "^2.0.3"
72
78
  }
73
79
  }
@@ -1,4 +1,34 @@
1
1
  export declare const ERC20_EVM: readonly [{
2
+ readonly type: "function";
3
+ readonly name: "name";
4
+ readonly inputs: readonly [];
5
+ readonly outputs: readonly [{
6
+ readonly name: "";
7
+ readonly type: "string";
8
+ readonly internalType: "string";
9
+ }];
10
+ readonly stateMutability: "view";
11
+ }, {
12
+ readonly type: "function";
13
+ readonly name: "symbol";
14
+ readonly inputs: readonly [];
15
+ readonly outputs: readonly [{
16
+ readonly name: "";
17
+ readonly type: "string";
18
+ readonly internalType: "string";
19
+ }];
20
+ readonly stateMutability: "view";
21
+ }, {
22
+ readonly type: "function";
23
+ readonly name: "decimals";
24
+ readonly inputs: readonly [];
25
+ readonly outputs: readonly [{
26
+ readonly name: "";
27
+ readonly type: "uint8";
28
+ readonly internalType: "uint8";
29
+ }];
30
+ readonly stateMutability: "view";
31
+ }, {
2
32
  readonly type: "function";
3
33
  readonly name: "balanceOf";
4
34
  readonly inputs: readonly [{
@@ -2,6 +2,27 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ERC20_EVM = void 0;
4
4
  exports.ERC20_EVM = [
5
+ {
6
+ type: 'function',
7
+ name: 'name',
8
+ inputs: [],
9
+ outputs: [{ name: '', type: 'string', internalType: 'string' }],
10
+ stateMutability: 'view',
11
+ },
12
+ {
13
+ type: 'function',
14
+ name: 'symbol',
15
+ inputs: [],
16
+ outputs: [{ name: '', type: 'string', internalType: 'string' }],
17
+ stateMutability: 'view',
18
+ },
19
+ {
20
+ type: 'function',
21
+ name: 'decimals',
22
+ inputs: [],
23
+ outputs: [{ name: '', type: 'uint8', internalType: 'uint8' }],
24
+ stateMutability: 'view',
25
+ },
5
26
  {
6
27
  type: 'function',
7
28
  name: 'balanceOf',
@@ -12,6 +12,6 @@ export { CEA_FACTORY_EVM } from './ceaFactory.evm';
12
12
  export { UNIVERSAL_GATEWAY_PC } from './universalGatewayPC.evm';
13
13
  export { UNIVERSAL_CORE_EVM } from './prc20.evm';
14
14
  export { UEA_FACTORY_ABI } from './uea-factory';
15
- export { IPC20_EVM, PC20_FACTORY_EVM, PC20_WRAPPER_EVM, EXTERNAL_GATEWAY_PC20_EVM, VAULT_PC20_FACTORY_EVM, } from './pc20.evm';
15
+ export { PC20_FACTORY_EVM, PC20_WRAPPER_EVM, EXTERNAL_GATEWAY_PC20_EVM, VAULT_PC20_FACTORY_EVM, } from './pc20.evm';
16
16
  export { WPC_EVM } from './wpc.evm';
17
17
  export { UNIV3_SWAP_ROUTER_EVM } from './uniswap-v3-router.evm';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.UNIV3_SWAP_ROUTER_EVM = exports.WPC_EVM = exports.VAULT_PC20_FACTORY_EVM = exports.EXTERNAL_GATEWAY_PC20_EVM = exports.PC20_WRAPPER_EVM = exports.PC20_FACTORY_EVM = exports.IPC20_EVM = exports.UEA_FACTORY_ABI = exports.UNIVERSAL_CORE_EVM = exports.UNIVERSAL_GATEWAY_PC = exports.CEA_FACTORY_EVM = exports.CEA_EVM = exports.SVM_GATEWAY_IDL = exports.ERC20_EVM = exports.UNIVERSAL_GATEWAY_V1_SEND = exports.UNIVERSAL_GATEWAY_V0 = exports.UEA_SVM = exports.UEA_EVM = exports.FACTORY_V1 = exports.FEE_LOCKER_SVM = exports.FEE_LOCKER_EVM = void 0;
3
+ exports.UNIV3_SWAP_ROUTER_EVM = exports.WPC_EVM = exports.VAULT_PC20_FACTORY_EVM = exports.EXTERNAL_GATEWAY_PC20_EVM = exports.PC20_WRAPPER_EVM = exports.PC20_FACTORY_EVM = exports.UEA_FACTORY_ABI = exports.UNIVERSAL_CORE_EVM = exports.UNIVERSAL_GATEWAY_PC = exports.CEA_FACTORY_EVM = exports.CEA_EVM = exports.SVM_GATEWAY_IDL = exports.ERC20_EVM = exports.UNIVERSAL_GATEWAY_V1_SEND = exports.UNIVERSAL_GATEWAY_V0 = exports.UEA_SVM = exports.UEA_EVM = exports.FACTORY_V1 = exports.FEE_LOCKER_SVM = exports.FEE_LOCKER_EVM = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  var feeLocker_evm_1 = require("./feeLocker.evm");
6
6
  Object.defineProperty(exports, "FEE_LOCKER_EVM", { enumerable: true, get: function () { return feeLocker_evm_1.FEE_LOCKER_EVM; } });
@@ -33,7 +33,6 @@ var uea_factory_1 = require("./uea-factory");
33
33
  Object.defineProperty(exports, "UEA_FACTORY_ABI", { enumerable: true, get: function () { return uea_factory_1.UEA_FACTORY_ABI; } });
34
34
  // PC20
35
35
  var pc20_evm_1 = require("./pc20.evm");
36
- Object.defineProperty(exports, "IPC20_EVM", { enumerable: true, get: function () { return pc20_evm_1.IPC20_EVM; } });
37
36
  Object.defineProperty(exports, "PC20_FACTORY_EVM", { enumerable: true, get: function () { return pc20_evm_1.PC20_FACTORY_EVM; } });
38
37
  Object.defineProperty(exports, "PC20_WRAPPER_EVM", { enumerable: true, get: function () { return pc20_evm_1.PC20_WRAPPER_EVM; } });
39
38
  Object.defineProperty(exports, "EXTERNAL_GATEWAY_PC20_EVM", { enumerable: true, get: function () { return pc20_evm_1.EXTERNAL_GATEWAY_PC20_EVM; } });
@@ -2,7 +2,6 @@
2
2
  * PC20 ABIs — minimal read surfaces for registry resolution and validation.
3
3
  *
4
4
  * Sources:
5
- * IPC20 push-chain-gateway-contracts/contracts/evm-gateway/src/interfaces/IPC20.sol
6
5
  * PC20Factory push-chain-gateway-contracts/contracts/evm-gateway/src/PC20Factory.sol
7
6
  * PC20Wrapper push-chain-gateway-contracts/contracts/evm-gateway/src/PC20Wrapper.sol
8
7
  * UniversalGateway push-chain-gateway-contracts/contracts/evm-gateway/src/UniversalGateway.sol
@@ -10,30 +9,6 @@
10
9
  * Read-only entries only. Writes go through the existing gateway ABIs — PC20
11
10
  * changes the payload, not the entrypoint.
12
11
  */
13
- /** Push-native token eligible for export. Presence of `pc20Metadata` IS the PC20 test. */
14
- export declare const IPC20_EVM: readonly [{
15
- readonly type: "function";
16
- readonly name: "pc20Metadata";
17
- readonly inputs: readonly [];
18
- readonly outputs: readonly [{
19
- readonly name: "name";
20
- readonly type: "string";
21
- readonly internalType: "string";
22
- }, {
23
- readonly name: "symbol";
24
- readonly type: "string";
25
- readonly internalType: "string";
26
- }, {
27
- readonly name: "decimals";
28
- readonly type: "uint8";
29
- readonly internalType: "uint8";
30
- }, {
31
- readonly name: "originAddress";
32
- readonly type: "address";
33
- readonly internalType: "address";
34
- }];
35
- readonly stateMutability: "view";
36
- }];
37
12
  /**
38
13
  * Destination-chain factory.
39
14
  *
@@ -3,7 +3,6 @@
3
3
  * PC20 ABIs — minimal read surfaces for registry resolution and validation.
4
4
  *
5
5
  * Sources:
6
- * IPC20 push-chain-gateway-contracts/contracts/evm-gateway/src/interfaces/IPC20.sol
7
6
  * PC20Factory push-chain-gateway-contracts/contracts/evm-gateway/src/PC20Factory.sol
8
7
  * PC20Wrapper push-chain-gateway-contracts/contracts/evm-gateway/src/PC20Wrapper.sol
9
8
  * UniversalGateway push-chain-gateway-contracts/contracts/evm-gateway/src/UniversalGateway.sol
@@ -12,22 +11,7 @@
12
11
  * changes the payload, not the entrypoint.
13
12
  */
14
13
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.EXTERNAL_GATEWAY_PC20_EVM = exports.VAULT_PC20_FACTORY_EVM = exports.PC20_WRAPPER_EVM = exports.PC20_FACTORY_EVM = exports.IPC20_EVM = void 0;
16
- /** Push-native token eligible for export. Presence of `pc20Metadata` IS the PC20 test. */
17
- exports.IPC20_EVM = [
18
- {
19
- type: 'function',
20
- name: 'pc20Metadata',
21
- inputs: [],
22
- outputs: [
23
- { name: 'name', type: 'string', internalType: 'string' },
24
- { name: 'symbol', type: 'string', internalType: 'string' },
25
- { name: 'decimals', type: 'uint8', internalType: 'uint8' },
26
- { name: 'originAddress', type: 'address', internalType: 'address' },
27
- ],
28
- stateMutability: 'view',
29
- },
30
- ];
14
+ exports.EXTERNAL_GATEWAY_PC20_EVM = exports.VAULT_PC20_FACTORY_EVM = exports.PC20_WRAPPER_EVM = exports.PC20_FACTORY_EVM = void 0;
31
15
  /**
32
16
  * Destination-chain factory.
33
17
  *
@@ -36,6 +36,22 @@ function getViemChain(chain) {
36
36
  }
37
37
  return viemChain;
38
38
  }
39
+ /**
40
+ * Transport for a CEAFactory read.
41
+ *
42
+ * `http(undefined)` falls back to the *viem* chain's default endpoint, which for
43
+ * `sepolia` is `https://sepolia.drpc.org` — a paid-plan host that answers every
44
+ * request with HTTP 400 ("chain is not available on free plan"). That silently
45
+ * broke every CEA derivation that did not pass an explicit rpcUrl, and no caller
46
+ * passes one. Use the SDK's own `CHAIN_INFO[].defaultRPC` list instead, wrapped in
47
+ * `fallback` so one dead endpoint rotates to the next.
48
+ */
49
+ function ceaTransport(chain, rpcUrl) {
50
+ if (rpcUrl)
51
+ return (0, viem_1.http)(rpcUrl);
52
+ const rpcs = chain_1.CHAIN_INFO[chain]?.defaultRPC ?? [];
53
+ return rpcs.length > 0 ? (0, viem_1.fallback)(rpcs.map((url) => (0, viem_1.http)(url))) : (0, viem_1.http)();
54
+ }
39
55
  /**
40
56
  * Get CEA address for a UEA on a specific chain
41
57
  *
@@ -58,7 +74,7 @@ async function getCEAAddress(ueaAddress, chain, rpcUrl) {
58
74
  const viemChain = getViemChain(chain);
59
75
  const client = (0, viem_1.createPublicClient)({
60
76
  chain: viemChain,
61
- transport: (0, viem_1.http)(rpcUrl),
77
+ transport: ceaTransport(chain, rpcUrl),
62
78
  });
63
79
  if (cachedAddress) {
64
80
  // Address is cached — only re-check deployment status
@@ -91,7 +107,7 @@ async function getPushAccountForCEA(ceaAddress, chain, rpcUrl) {
91
107
  const viemChain = getViemChain(chain);
92
108
  const client = (0, viem_1.createPublicClient)({
93
109
  chain: viemChain,
94
- transport: (0, viem_1.http)(rpcUrl),
110
+ transport: ceaTransport(chain, rpcUrl),
95
111
  });
96
112
  const uea = await client.readContract({
97
113
  abi: ceaFactory_evm_1.CEA_FACTORY_EVM,
@@ -118,7 +134,7 @@ async function isCEA(address, chain, rpcUrl) {
118
134
  const viemChain = getViemChain(chain);
119
135
  const client = (0, viem_1.createPublicClient)({
120
136
  chain: viemChain,
121
- transport: (0, viem_1.http)(rpcUrl),
137
+ transport: ceaTransport(chain, rpcUrl),
122
138
  });
123
139
  return client.readContract({
124
140
  abi: ceaFactory_evm_1.CEA_FACTORY_EVM,
@@ -31,11 +31,19 @@ const response_builder_1 = require("./response-builder");
31
31
  const push_chain_tx_1 = require("./push-chain-tx");
32
32
  const svm_helpers_1 = require("./svm-helpers");
33
33
  const svm_1 = require("./pc20/svm");
34
+ const gate_1 = require("./pc20/gate");
34
35
  const helpers_2 = require("./helpers");
35
36
  const response_builder_2 = require("./response-builder");
36
37
  const formatters_1 = require("../../formatters");
37
38
  async function executeFundsOnly(ctx, execute, eventBuffer, getResponseCallbacks) {
38
39
  const transformFn = (tx, buf = []) => (0, response_builder_2.transformToUniversalTxResponse)(ctx, tx, buf, getResponseCallbacks());
40
+ // Fail closed: a PC20 wrapper burn must never be broadcast from this path.
41
+ // It sends an empty payload, the gateway prepends the PC20 selector, and the
42
+ // chain's inbound decode hard-fails on the selector-only result with no
43
+ // revert — burning the wrapper and locking the canonical token forever.
44
+ // The orchestrator routes PC20 imports to the payload path; this is the
45
+ // backstop for any caller that reaches here anyway.
46
+ (0, gate_1.assertPC20ImportHasPayload)(execute, false, 'executeFundsOnly');
39
47
  const chain = ctx.universalSigner.account.chain;
40
48
  const { vm } = chain_1.CHAIN_INFO[chain];
41
49
  if (!helpers_1.SUPPORTED_GATEWAY_CHAINS.includes(chain)) {
@@ -75,7 +75,7 @@ export declare class PC20FactoryMismatchError extends PC20Error {
75
75
  registryFactory?: string;
76
76
  });
77
77
  }
78
- /** Token does not implement `pc20Metadata()`, or the metadata fails validation. */
78
+ /** Token does not expose usable ERC-20 metadata, or the metadata fails validation. */
79
79
  export declare class InvalidPC20MetadataError extends PC20Error {
80
80
  constructor(message: string, ctx?: PC20ErrorContext);
81
81
  }
@@ -108,7 +108,7 @@ class PC20FactoryMismatchError extends PC20Error {
108
108
  }
109
109
  }
110
110
  exports.PC20FactoryMismatchError = PC20FactoryMismatchError;
111
- /** Token does not implement `pc20Metadata()`, or the metadata fails validation. */
111
+ /** Token does not expose usable ERC-20 metadata, or the metadata fails validation. */
112
112
  class InvalidPC20MetadataError extends PC20Error {
113
113
  constructor(message, ctx = {}) {
114
114
  super('PC20_INVALID_METADATA', message, ctx);
@@ -61,6 +61,8 @@ export type PC20GasQuote = {
61
61
  protocolFee: bigint;
62
62
  gasPrice: bigint;
63
63
  chainNamespace: string;
64
+ /** Value encoded in the outbound request before UGPC applies its overhead. */
65
+ requestGasLimit: bigint;
64
66
  gasLimitUsed: bigint;
65
67
  isFirstExport: boolean;
66
68
  /**
@@ -84,10 +86,11 @@ export type PC20GasQuote = {
84
86
  * `pc20DeploymentGasOverhead` and the transfer strands on the destination
85
87
  * with the source token already locked.
86
88
  *
87
- * The second is unrecoverable without operator intervention, so the ceiling
88
- * always covers a deployment. `maxPCForGas` is a ceiling rather than a charge —
89
- * the unspent remainder is not consumed — so the only cost is a higher required
90
- * balance. `gasLimitUsed` itself is passed through unchanged.
89
+ * The second is unrecoverable without operator intervention, so the execution
90
+ * gas limit always covers a deployment. The buffered limit must be re-quoted
91
+ * and written into the outbound request; funding a higher ceiling while leaving
92
+ * the request at the default limit still makes the relayer submit an
93
+ * under-gassed destination transaction.
91
94
  */
92
95
  export declare function quotePC20Export(ctx: OrchestratorContext, pushPC20: `0x${string}`, destinationChain: CHAIN, gasLimit: bigint, opts: PC20ResolverOptions): Promise<PC20GasQuote>;
93
96
  /**
@@ -103,6 +106,7 @@ export declare function queryPC20OutboundGasFee(ctx: OrchestratorContext, pushPC
103
106
  gasFee: bigint;
104
107
  protocolFee: bigint;
105
108
  gasPrice: bigint;
109
+ requestGasLimit: bigint;
106
110
  gasLimitUsed: bigint;
107
111
  universalCoreAddress: `0x${string}`;
108
112
  isFirstExport: boolean;
@@ -47,6 +47,7 @@ exports.queryPC20OutboundGasFee = queryPC20OutboundGasFee;
47
47
  exports.resolveDestinationWrapper = resolveDestinationWrapper;
48
48
  exports.validatePC20Export = validatePC20Export;
49
49
  const viem_1 = require("viem");
50
+ const enums_1 = require("../../../constants/enums");
50
51
  const abi_1 = require("../../../constants/abi");
51
52
  const chain_1 = require("../../../constants/chain");
52
53
  const context_1 = require("../context");
@@ -123,38 +124,55 @@ function assertMetadataFitsFactoryLimits(name, symbol) {
123
124
  * `pc20DeploymentGasOverhead` and the transfer strands on the destination
124
125
  * with the source token already locked.
125
126
  *
126
- * The second is unrecoverable without operator intervention, so the ceiling
127
- * always covers a deployment. `maxPCForGas` is a ceiling rather than a charge —
128
- * the unspent remainder is not consumed — so the only cost is a higher required
129
- * balance. `gasLimitUsed` itself is passed through unchanged.
127
+ * The second is unrecoverable without operator intervention, so the execution
128
+ * gas limit always covers a deployment. The buffered limit must be re-quoted
129
+ * and written into the outbound request; funding a higher ceiling while leaving
130
+ * the request at the default limit still makes the relayer submit an
131
+ * under-gassed destination transaction.
130
132
  */
131
133
  async function quotePC20Export(ctx, pushPC20, destinationChain, gasLimit, opts) {
132
134
  const namespace = (0, chain_namespace_1.chainToNamespace)(destinationChain);
133
135
  const core = await (0, resolver_1.getUniversalCoreAddress)(opts);
134
- const result = await ctx.pushClient.readContract({
136
+ const readQuote = (limit) => ctx.pushClient.readContract({
135
137
  address: core,
136
138
  abi: abi_1.UNIVERSAL_CORE_EVM,
137
139
  functionName: 'getPC20ExportGasAndFees',
138
- args: [namespace, gasLimit, pushPC20],
140
+ args: [namespace, limit, pushPC20],
139
141
  });
140
- const [gasToken, gasFee, protocolFee, gasPrice, chainNamespace, gasLimitUsed, isFirstExport] = result;
141
- const overhead = await ctx.pushClient.readContract({
142
- address: core,
143
- abi: abi_1.UNIVERSAL_CORE_EVM,
144
- functionName: 'pc20DeploymentGasOverhead',
145
- args: [namespace],
146
- });
147
- // Add the overhead even when the quote already included it. Double-counting
148
- // raises a ceiling that is never fully spent; under-counting strands funds.
149
- const gasLimitCeiling = gasLimitUsed + overhead;
150
- (0, context_1.printLog)(ctx, `quotePC20Export — namespace=${chainNamespace}, gasLimitUsed=${gasLimitUsed}, ` +
151
- `isFirstExport=${isFirstExport}, overhead=${overhead}, ceiling=${gasLimitCeiling}`);
142
+ const [baseQuote, overhead] = await Promise.all([
143
+ readQuote(gasLimit),
144
+ ctx.pushClient.readContract({
145
+ address: core,
146
+ abi: abi_1.UNIVERSAL_CORE_EVM,
147
+ functionName: 'pc20DeploymentGasOverhead',
148
+ args: [namespace],
149
+ }),
150
+ ]);
151
+ const baseGasLimitUsed = baseQuote[5];
152
+ const isFirstExport = baseQuote[6];
153
+ // Feed the resolved first quote back as the explicit request limit. UGPC
154
+ // applies its deployment overhead to that value, giving one additional
155
+ // safety margin while keeping the fee quote and final destination limit in
156
+ // sync. Passing `gasLimitCeiling` here would make UGPC add the overhead yet
157
+ // again and over-provision the relayer transaction.
158
+ const gasLimitCeiling = baseGasLimitUsed + overhead;
159
+ const isEvmDestination = (0, chain_namespace_1.vmForChain)(destinationChain) === enums_1.VM.EVM;
160
+ const requestGasLimit = isEvmDestination ? baseGasLimitUsed : gasLimit;
161
+ // Solana has a separate rent/compute-budget requote path; preserve its base
162
+ // quote here so this EVM wrapper-deployment guard does not double-count it.
163
+ const bufferedQuote = isEvmDestination
164
+ ? await readQuote(requestGasLimit)
165
+ : baseQuote;
166
+ const [gasToken, gasFee, protocolFee, gasPrice, chainNamespace, gasLimitUsed] = bufferedQuote;
167
+ (0, context_1.printLog)(ctx, `quotePC20Export — namespace=${chainNamespace}, baseGasLimit=${baseGasLimitUsed}, ` +
168
+ `isFirstExport=${isFirstExport}, overhead=${overhead}, executionGasLimit=${gasLimitUsed}`);
152
169
  return {
153
170
  gasToken,
154
171
  gasFee,
155
172
  protocolFee,
156
173
  gasPrice,
157
174
  chainNamespace,
175
+ requestGasLimit,
158
176
  gasLimitUsed,
159
177
  isFirstExport,
160
178
  gasLimitCeiling,
@@ -180,6 +198,7 @@ async function queryPC20OutboundGasFee(ctx, pushPC20, gasLimit, destinationChain
180
198
  gasFee: quote.gasFee,
181
199
  protocolFee: quote.protocolFee,
182
200
  gasPrice: quote.gasPrice,
201
+ requestGasLimit: quote.requestGasLimit,
183
202
  gasLimitUsed: quote.gasLimitUsed,
184
203
  universalCoreAddress,
185
204
  isFirstExport: quote.isFirstExport,
@@ -36,12 +36,11 @@
36
36
  */
37
37
  import { type Hex } from 'viem';
38
38
  import { CHAIN, PUSH_NETWORK, VM } from '../../../constants/enums';
39
- /** Metadata read off a Push-native PC20 via `pc20Metadata()`. */
39
+ /** Standard ERC-20 metadata forwarded when exporting a Push-native PC20. */
40
40
  export type PC20Metadata = {
41
41
  name: string;
42
42
  symbol: string;
43
43
  decimals: number;
44
- originAddress: `0x${string}`;
45
44
  };
46
45
  /** A confirmed wrapper deployment on one external chain. */
47
46
  export type PC20DeploymentInfo = {
@@ -122,12 +121,11 @@ export declare function resolveWrapperToSource(chain: CHAIN, address: string, op
122
121
  */
123
122
  export declare function verifyEvmWrapperIdentity(chain: CHAIN, address: string, pushAddress: `0x${string}`, opts: PC20ResolverOptions): Promise<void>;
124
123
  /**
125
- * Read and validate `pc20Metadata()` on a Push-native token.
124
+ * Read and validate standard ERC-20 metadata on a Push-native token.
126
125
  *
127
- * The absence of `pc20Metadata` is the discriminator between a PC20 and any
128
- * other ERC20 — never the symbol. A synthetic PRC20 is detected separately so
129
- * it gets an error that points at the right API rather than reading as
130
- * "your token is broken".
126
+ * Every metadata-compatible ERC-20 born on Push Chain is eligible for PC20
127
+ * export. Synthetic PRC20s expose `CHAIN_NAMESPACE()` and are rejected even
128
+ * though they also implement the standard metadata functions.
131
129
  */
132
130
  export declare function readPushPC20Metadata(pushAddress: `0x${string}`, opts: PC20ResolverOptions): Promise<PC20Metadata>;
133
131
  /**
@@ -111,10 +111,9 @@ function clientFor(chain, opts) {
111
111
  * dispatches them synchronously, which is what puts them in the same batch
112
112
  * window — awaiting them one at a time would silently become N round trips.
113
113
  *
114
- * Failures are captured per call rather than thrown, because several PC20 probes
115
- * are *expected* to revert: `pc20Metadata()` reverting on a plain ERC20 is the
116
- * primary discriminator, not an error condition. Callers interpret each result
117
- * individually via {@link unwrap}.
114
+ * Failures are captured per call rather than thrown, because optional identity
115
+ * probes such as `CHAIN_NAMESPACE()` are expected to revert on ordinary ERC-20s.
116
+ * Callers interpret each result individually via {@link unwrap}.
118
117
  */
119
118
  async function batchRead(client, contracts) {
120
119
  exports.__readCount.n += 1;
@@ -376,12 +375,11 @@ async function verifyEvmWrapperIdentity(chain, address, pushAddress, opts) {
376
375
  // Push-native metadata
377
376
  // ---------------------------------------------------------------------------
378
377
  /**
379
- * Read and validate `pc20Metadata()` on a Push-native token.
378
+ * Read and validate standard ERC-20 metadata on a Push-native token.
380
379
  *
381
- * The absence of `pc20Metadata` is the discriminator between a PC20 and any
382
- * other ERC20 — never the symbol. A synthetic PRC20 is detected separately so
383
- * it gets an error that points at the right API rather than reading as
384
- * "your token is broken".
380
+ * Every metadata-compatible ERC-20 born on Push Chain is eligible for PC20
381
+ * export. Synthetic PRC20s expose `CHAIN_NAMESPACE()` and are rejected even
382
+ * though they also implement the standard metadata functions.
385
383
  */
386
384
  async function readPushPC20Metadata(pushAddress, opts) {
387
385
  const pushChain = (0, helpers_1.getPushChainForNetwork)(opts.network);
@@ -390,10 +388,26 @@ async function readPushPC20Metadata(pushAddress, opts) {
390
388
  if (cached)
391
389
  return cached;
392
390
  const client = clientFor(pushChain, opts);
393
- const [metaEntry, codeEntry] = await Promise.all([
391
+ const [metadataEntries, codeEntry] = await Promise.all([
394
392
  batchRead(client, [
395
- { address: pushAddress, abi: abi_1.IPC20_EVM, functionName: 'pc20Metadata', args: [] },
396
- ]).then((r) => r[0]),
393
+ { address: pushAddress, abi: abi_1.ERC20_EVM, functionName: 'name', args: [] },
394
+ { address: pushAddress, abi: abi_1.ERC20_EVM, functionName: 'symbol', args: [] },
395
+ { address: pushAddress, abi: abi_1.ERC20_EVM, functionName: 'decimals', args: [] },
396
+ {
397
+ address: pushAddress,
398
+ abi: [
399
+ {
400
+ type: 'function',
401
+ name: 'CHAIN_NAMESPACE',
402
+ inputs: [],
403
+ outputs: [{ name: '', type: 'string' }],
404
+ stateMutability: 'view',
405
+ },
406
+ ],
407
+ functionName: 'CHAIN_NAMESPACE',
408
+ args: [],
409
+ },
410
+ ]),
397
411
  (async () => {
398
412
  exports.__readCount.n += 1;
399
413
  return { status: 'success', result: await client.getCode({ address: pushAddress }) };
@@ -406,22 +420,19 @@ async function readPushPC20Metadata(pushAddress, opts) {
406
420
  address: pushAddress,
407
421
  });
408
422
  }
409
- const meta = unwrap(metaEntry);
410
- if (!meta) {
411
- if (await isSyntheticPRC20(pushAddress, opts)) {
412
- throw new errors_1.PC20ExpectedButPRC20Error({
413
- chain: String(pushChain),
414
- address: pushAddress,
415
- });
416
- }
417
- throw new errors_1.InvalidPC20MetadataError('Token does not implement pc20Metadata() and is not a Push-native PC20.', { chain: String(pushChain), address: pushAddress });
423
+ const prc20Namespace = unwrap(metadataEntries[3]);
424
+ if (typeof prc20Namespace === 'string') {
425
+ throw new errors_1.PC20ExpectedButPRC20Error({
426
+ chain: String(pushChain),
427
+ address: pushAddress,
428
+ chainNamespace: prc20Namespace,
429
+ });
418
430
  }
419
- const [name, symbol, decimals, originAddress] = meta;
420
- // The origin binding is what makes the metadata trustworthy: a contract can
421
- // return any name it likes, but claiming to originate at a different address
422
- // means it is not the canonical source for this token.
423
- if ((0, viem_1.getAddress)(originAddress) !== (0, viem_1.getAddress)(pushAddress)) {
424
- throw new errors_1.InvalidPC20MetadataError(`pc20Metadata().originAddress (${originAddress}) does not match the token address.`, { chain: String(pushChain), address: pushAddress });
431
+ const name = unwrap(metadataEntries[0]);
432
+ const symbol = unwrap(metadataEntries[1]);
433
+ const decimals = unwrap(metadataEntries[2]);
434
+ if (name === undefined || symbol === undefined || decimals === undefined) {
435
+ throw new errors_1.InvalidPC20MetadataError('Token must implement the ERC-20 metadata functions name(), symbol(), and decimals().', { chain: String(pushChain), address: pushAddress });
425
436
  }
426
437
  if (!name || !symbol) {
427
438
  throw new errors_1.InvalidPC20MetadataError('PC20 name and symbol must be non-empty.', {
@@ -429,41 +440,21 @@ async function readPushPC20Metadata(pushAddress, opts) {
429
440
  address: pushAddress,
430
441
  });
431
442
  }
443
+ const normalizedDecimals = Number(decimals);
444
+ if (!Number.isInteger(normalizedDecimals) || normalizedDecimals < 0 || normalizedDecimals > 255) {
445
+ throw new errors_1.InvalidPC20MetadataError('PC20 decimals must be a valid uint8 value.', {
446
+ chain: String(pushChain),
447
+ address: pushAddress,
448
+ });
449
+ }
432
450
  const validated = {
433
451
  name,
434
452
  symbol,
435
- decimals: Number(decimals),
436
- originAddress: (0, viem_1.getAddress)(originAddress),
453
+ decimals: normalizedDecimals,
437
454
  };
438
455
  metadataCache.set(key, validated);
439
456
  return validated;
440
457
  }
441
- /**
442
- * Distinguish a synthetic PRC20 from an arbitrary ERC20.
443
- *
444
- * PRC20s expose the origin chain they wrap; PC20s do not. Checked only after
445
- * `pc20Metadata()` has already failed, so it costs nothing on the happy path.
446
- */
447
- async function isSyntheticPRC20(address, opts) {
448
- const client = clientFor((0, helpers_1.getPushChainForNetwork)(opts.network), opts);
449
- const [entry] = await batchRead(client, [
450
- {
451
- address,
452
- abi: [
453
- {
454
- type: 'function',
455
- name: 'CHAIN_NAMESPACE',
456
- inputs: [],
457
- outputs: [{ name: '', type: 'string' }],
458
- stateMutability: 'view',
459
- },
460
- ],
461
- functionName: 'CHAIN_NAMESPACE',
462
- args: [],
463
- },
464
- ]);
465
- return typeof unwrap(entry) === 'string';
466
- }
467
458
  // ---------------------------------------------------------------------------
468
459
  // Destination-side lookups
469
460
  // ---------------------------------------------------------------------------
@@ -487,6 +487,7 @@ async function executeUoaToCea(ctx, params, executeFn) {
487
487
  (0, context_1.printLog)(ctx, `executeUoaToCea — PAYLOAD-only: using native PRC-20 ${prc20Token} for chain ${targetChain} with zero burn amount`);
488
488
  }
489
489
  const gasLimitForQuery = params.gasLimit ?? BigInt(0);
490
+ let outboundGasLimit = gasLimitForQuery;
490
491
  // --- 202: Gas estimation (spec-ordered before 203) ---
491
492
  let gasFee = BigInt(0);
492
493
  let protocolFee = BigInt(0);
@@ -499,9 +500,20 @@ async function executeUoaToCea(ctx, params, executeFn) {
499
500
  // getPC20ExportGasAndFees accounts for the first-export deployment
500
501
  // overhead, and quoting the wrong one strands the destination transfer.
501
502
  const pc20Export = params._pc20;
502
- const result = pc20Export?.direction === 'export'
503
- ? await (0, export_1.queryPC20OutboundGasFee)(ctx, prc20Token, gasLimitForQuery, targetChain)
504
- : await (0, gas_calculator_1.queryOutboundGasFee)(ctx, prc20Token, gasLimitForQuery, targetChain);
503
+ const isPc20ExportQuote = pc20Export?.direction === 'export';
504
+ let result;
505
+ if (isPc20ExportQuote) {
506
+ const pc20Quote = await (0, export_1.queryPC20OutboundGasFee)(ctx, prc20Token, gasLimitForQuery, targetChain);
507
+ result = pc20Quote;
508
+ // PC20 first exports deploy a wrapper on the destination. The quote is
509
+ // re-issued with that deployment margin, and the relayer must receive
510
+ // the corresponding explicit request limit—not the caller's original
511
+ // zero/default.
512
+ outboundGasLimit = pc20Quote.requestGasLimit;
513
+ }
514
+ else {
515
+ result = await (0, gas_calculator_1.queryOutboundGasFee)(ctx, prc20Token, gasLimitForQuery, targetChain);
516
+ }
505
517
  gasFee = result.gasFee;
506
518
  protocolFee = result.protocolFee;
507
519
  gasToken = result.gasToken;
@@ -567,7 +579,7 @@ async function executeUoaToCea(ctx, params, executeFn) {
567
579
  destinationUserData: ceaPayload,
568
580
  })
569
581
  : ceaPayload;
570
- const outboundReq = (0, payload_builders_2.buildOutboundRequest)(targetBytes, prc20Token, burnAmount, gasLimitForQuery, outboundPayload, ueaAddress, // revert recipient is the UEA
582
+ const outboundReq = (0, payload_builders_2.buildOutboundRequest)(targetBytes, prc20Token, burnAmount, outboundGasLimit, outboundPayload, ueaAddress, // revert recipient is the UEA
571
583
  params.maxPCForGas ?? BigInt(0));
572
584
  (0, context_1.printLog)(ctx, `executeUoaToCea — outbound request: ${JSON.stringify({
573
585
  recipient: outboundReq.recipient,
@@ -266,8 +266,8 @@ class Orchestrator {
266
266
  // Single PC20 gate. Downstream builders only ever see the legacy
267
267
  // PRC20/native shape; the PC20 branch is added here in Phase 2.
268
268
  const legacy = await (0, gate_1.gateFunds)(this.ctx, execute);
269
- // SVM PC20 imports never take the raw funds-only path: the Solana
270
- // gateway prepends the PC20 selector to the (empty) payload and
269
+ // PC20 imports never take the raw funds-only path, on EITHER VM.
270
+ // The gateway prepends the PC20 selector to the (empty) payload and
271
271
  // classifies the RESULT as FUNDS_AND_PAYLOAD, and the chain's
272
272
  // inbound decode hard-fails on that selector-only payload with no
273
273
  // revert — the wrapper is already burned (same "0x" vs "" guard as
@@ -275,9 +275,18 @@ class Orchestrator {
275
275
  // payload (UEA multicall transfer to `to`), which classifies and
276
276
  // decodes legitimately — the same always-forward mitigation R3
277
277
  // ships on both VMs.
278
- const svmPc20Import = legacy._pc20?.direction === 'import' &&
279
- chain_1.CHAIN_INFO[this.ctx.universalSigner.account.chain].vm === enums_1.VM.SVM;
280
- return attachPerCallHookForWait(!hasData && !svmPc20Import
278
+ //
279
+ // This was previously gated on VM.SVM, which left EVM wrapper burns
280
+ // on the unsafe path: observed on Donut as a burned wrapper whose
281
+ // canonical token was never unlocked. The brick is at ballot
282
+ // finalization — NormalizeForTxType (x/uexecutor/types/inbound.go)
283
+ // strips the selector, gets '0x', which passes its `!= ""` empty
284
+ // check, and DecodeRawPayload('0x') then fails — so no handler runs
285
+ // at all: no deploy, no credit, and no INBOUND_REVERT. With a real
286
+ // forward payload the decode succeeds and the funds_and_payload
287
+ // handler deploys the UEA and credits normally.
288
+ const pc20Import = legacy._pc20?.direction === 'import';
289
+ return attachPerCallHookForWait(!hasData && !pc20Import
281
290
  ? await (0, internals_1.executeFundsOnly)(this.ctx, legacy, eventBuffer, rcb)
282
291
  : await (0, internals_1.executeFundsWithPayload)(this.ctx, legacy, eventBuffer, rcb));
283
292
  }