Skip to content

Commit 29135ae

Browse files
authored
Merge pull request #286 from pushchain/feat/pc20
feat: PC20 Feature
2 parents e4b746a + dde0f87 commit 29135ae

49 files changed

Lines changed: 5697 additions & 710 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/uexecutor/v1/types.pulsar.go

Lines changed: 561 additions & 280 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

proto/uexecutor/v1/types.proto

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ message Inbound {
117117
RevertInstructions revert_instructions = 11; // revert config
118118
bool isCEA = 12; // isCEA is the boolean indicating this inbound is initiated by a CEA
119119
string raw_payload = 13; // hex-encoded raw payload bytes from source chain event; decoded into universal_payload by the core validator
120+
bool is_pc20 = 14; // true when the raw payload carried the PC20 selector (a PC20 return: burn wrapper -> unlock locked native); set during NormalizeForTxType after the selector is stripped
120121
}
121122

122123
message PCTx {
@@ -141,6 +142,7 @@ message OutboundObservation {
141142
string tx_hash = 3; // external chain tx hash
142143
string error_msg = 4;
143144
string gas_fee_used = 5; // actual gas fee consumed on destination chain; used to compute excess refund
145+
string pc20_wrapper_address = 6; // PC20 only: wrapper address deployed on the destination chain (observed at settlement), used to flip the PC20 deploy flag
144146
}
145147

146148
message OriginatingPcTx {
@@ -177,6 +179,8 @@ message OutboundTx {
177179
string refund_swap_error = 19; // non-empty if swap-refund failed and we fell back to no-swap
178180
string gas_token = 20; // gas token PRC20 address used to pay relayer fee
179181
string abort_reason = 21; // Human-readable reason why the outbound was aborted
182+
bool is_pc20 = 22; // true when this outbound is a PC20 export (lock native on Push -> mint wrapper on dest); routes settlement/revert to the PC20 path
183+
string pc20_contract_address = 23; // PC20 only: the Push-native PC20 token contract address (the locked source asset); used for VaultPC20.revertExport and the deploy-flag registry
180184
}
181185

182186
message UniversalTx {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package integrationtest
2+
3+
import (
4+
"testing"
5+
6+
sdk "github.com/cosmos/cosmos-sdk/types"
7+
"github.com/stretchr/testify/require"
8+
9+
utils "github.com/pushchain/push-chain-node/test/utils"
10+
uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types"
11+
)
12+
13+
// TestInboundRevertGasNotRefunded proves an INBOUND_REVERT never refunds gas on
14+
// settlement. A revert is protocol-initiated — the user was never charged a gas fee
15+
// for it — so even when a GasFee budget is present (PRC20 reverts set one) and the
16+
// observed gasFeeUsed is well below it, no refund must be attempted.
17+
func TestInboundRevertGasNotRefunded(t *testing.T) {
18+
chainApp, ctx, vals, utxId, ob, coreVals := setupOutboundVotingTest(t, 4)
19+
20+
// Make the seeded outbound an INBOUND_REVERT carrying a gas budget with headroom
21+
// that would otherwise trigger a refund (GasFee 1000, gasFeeUsed 100 below).
22+
ob.TxType = uexecutortypes.TxType_INBOUND_REVERT
23+
ob.GasFee = "1000"
24+
ob.GasToken = "0x000000000000000000000000000000000000C0dE"
25+
require.NoError(t, chainApp.UexecutorKeeper.UpdateOutbound(ctx, utxId, *ob))
26+
27+
// Settle it successfully with gasFeeUsed << GasFee.
28+
for i := 0; i < 3; i++ {
29+
valAddr, err := sdk.ValAddressFromBech32(coreVals[i].OperatorAddress)
30+
require.NoError(t, err)
31+
require.NoError(t, utils.ExecVoteOutbound(
32+
t, ctx, chainApp, vals[i], sdk.AccAddress(valAddr).String(), utxId, ob, true, "", "100"))
33+
}
34+
35+
utx, found, err := chainApp.UexecutorKeeper.GetUniversalTx(ctx, utxId)
36+
require.NoError(t, err)
37+
require.True(t, found)
38+
require.Nil(t, utx.OutboundTx[0].PcRefundExecution,
39+
"INBOUND_REVERT must not attempt a gas refund — the user was never charged for it")
40+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package integrationtest
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
sdk "github.com/cosmos/cosmos-sdk/types"
8+
"github.com/ethereum/go-ethereum/common"
9+
"github.com/mr-tron/base58"
10+
"github.com/stretchr/testify/require"
11+
12+
utils "github.com/pushchain/push-chain-node/test/utils"
13+
uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types"
14+
)
15+
16+
// TestIsUeModuleAddress verifies the module-address check compares raw 20-byte
17+
// values (VM-native identity), so hex casing / EIP-55 checksum never matters and
18+
// a re-parsed lower/upper-case form of the module address still resolves as the
19+
// module — while any other address does not.
20+
func TestIsUeModuleAddress(t *testing.T) {
21+
chainApp, ctx, _ := utils.SetAppWithValidators(t)
22+
k := chainApp.UexecutorKeeper
23+
24+
moduleAddr, moduleHex := k.GetUeModuleAddress(ctx)
25+
26+
require.True(t, k.IsUeModuleAddress(ctx, moduleAddr), "the module address must resolve as the module")
27+
// Re-parsed from all-lowercase hex → identical 20 bytes → still the module.
28+
require.True(t, k.IsUeModuleAddress(ctx, common.HexToAddress(strings.ToLower(moduleHex))))
29+
// Re-parsed from all-uppercase (non-checksummed) hex → identical 20 bytes → still the module.
30+
require.True(t, k.IsUeModuleAddress(ctx, common.HexToAddress("0x"+strings.ToUpper(strings.TrimPrefix(moduleHex, "0x")))))
31+
32+
require.False(t, k.IsUeModuleAddress(ctx, common.HexToAddress("0x00000000000000000000000000000000dEadBeef")), "a different address is not the module")
33+
require.False(t, k.IsUeModuleAddress(ctx, common.HexToAddress("0x1234567890AbCdeF1234567890abCDeF12345678")), "an unrelated EVM address is not the module")
34+
require.False(t, k.IsUeModuleAddress(ctx, common.Address{}), "the zero address is not the module")
35+
36+
// A Solana (SVM) address folded into 20 bytes must not collide with the module.
37+
solBytes, err := base58.Decode("So11111111111111111111111111111111111111112")
38+
require.NoError(t, err)
39+
require.False(t, k.IsUeModuleAddress(ctx, common.BytesToAddress(solBytes)), "an SVM-derived address is not the module")
40+
}
41+
42+
// TestModuleNonce_NonModuleSenderDoesNotAdvanceModuleNonce proves the module-nonce
43+
// counter is only touched for a module sender. A non-module `from` skips the
44+
// module-nonce path entirely (before any EVM work), so ModuleAccountNonce is
45+
// unchanged regardless of whether the downstream EVM call itself succeeds.
46+
func TestModuleNonce_NonModuleSenderDoesNotAdvanceModuleNonce(t *testing.T) {
47+
chainApp, ctx, _ := utils.SetAppWithValidators(t)
48+
k := chainApp.UexecutorKeeper
49+
50+
before, err := k.GetModuleAccountNonce(ctx)
51+
require.NoError(t, err)
52+
53+
user := common.HexToAddress("0x00000000000000000000000000000000000000A1")
54+
uea := common.HexToAddress("0x00000000000000000000000000000000000000A2")
55+
payload := &uexecutortypes.UniversalPayload{
56+
To: user.Hex(), Value: "0", Data: "0x", GasLimit: "100000",
57+
MaxFeePerGas: "0", MaxPriorityFeePerGas: "0", Nonce: "0", Deadline: "0",
58+
}
59+
// The EVM call may fail (no real UEA / no signature) — irrelevant to nonce
60+
// accounting, which is decided before the call.
61+
_, _ = k.CallUEAExecutePayload(ctx, user, uea, payload, nil)
62+
63+
after, err := k.GetModuleAccountNonce(ctx)
64+
require.NoError(t, err)
65+
require.Equal(t, before, after, "a non-module sender must not advance ModuleAccountNonce")
66+
}
67+
68+
// TestModuleNonce_ModuleSenderAdvancesModuleNonce is the isolated regression: a
69+
// UEA payload execution sent by the module must advance ModuleAccountNonce by one.
70+
// Before the fix this call sourced its nonce from the (static) account sequence and
71+
// left ModuleAccountNonce untouched, so it silently reused whatever nonce a prior
72+
// module call in the same inbound had already used.
73+
func TestModuleNonce_ModuleSenderAdvancesModuleNonce(t *testing.T) {
74+
app, ctx, _, _, _, ueaAddr := setupInboundInitiatedOutboundTest(t, 4)
75+
k := app.UexecutorKeeper
76+
moduleAddr, _ := k.GetUeModuleAddress(ctx)
77+
78+
before, err := k.GetModuleAccountNonce(ctx)
79+
require.NoError(t, err)
80+
81+
// A minimal, freshly-nonced payload (UEA nonce is 0 right after deployment). The
82+
// module sender bypasses the UEA signature check, so this executes; either way
83+
// the module-nonce path runs before the EVM call.
84+
payload := &uexecutortypes.UniversalPayload{
85+
To: "0x00000000000000000000000000000000000000A9", Value: "0", Data: "0x",
86+
GasLimit: "200000", MaxFeePerGas: "0", MaxPriorityFeePerGas: "0", Nonce: "0", Deadline: "0",
87+
}
88+
_, _ = k.CallUEAExecutePayload(ctx, moduleAddr, ueaAddr, payload, nil)
89+
90+
after, err := k.GetModuleAccountNonce(ctx)
91+
require.NoError(t, err)
92+
require.Equal(t, before+1, after, "a module-sender UEA execution must advance ModuleAccountNonce by exactly 1")
93+
}
94+
95+
// TestModuleNonce_InboundPayloadExecutionAdvancesModuleNonce is the end-to-end
96+
// regression. A FUNDS_AND_PAYLOAD inbound makes two module-sender EVM calls — the
97+
// funds deposit AND the UEA payload execution — and each must advance
98+
// ModuleAccountNonce (Δ2). Before the fix the payload execution didn't, so Δ was 1
99+
// and the UEA tx reused the deposit's nonce.
100+
func TestModuleNonce_InboundPayloadExecutionAdvancesModuleNonce(t *testing.T) {
101+
app, ctx, vals, inbound, coreVals, _ := setupInboundInitiatedOutboundTest(t, 4)
102+
k := app.UexecutorKeeper
103+
104+
before, err := k.GetModuleAccountNonce(ctx)
105+
require.NoError(t, err)
106+
107+
for i := 0; i < 3; i++ {
108+
valAddr, err := sdk.ValAddressFromBech32(coreVals[i].OperatorAddress)
109+
require.NoError(t, err)
110+
require.NoError(t, utils.ExecVoteInbound(t, ctx, app, vals[i], sdk.AccAddress(valAddr).String(), inbound))
111+
}
112+
113+
after, err := k.GetModuleAccountNonce(ctx)
114+
require.NoError(t, err)
115+
require.Equal(t, uint64(2), after-before,
116+
"deposit + UEA payload execution must each advance ModuleAccountNonce (Δ2); pre-fix Δ was 1")
117+
}
118+
119+
// TestModuleNonce_ModuleDeployUEAAdvancesModuleNonce covers the CallFactoryToDeployUEA
120+
// path: the inbound flow deploys a UEA for a first-time user as the module, so that
121+
// deployment must also advance ModuleAccountNonce. Before the fix it ran the module
122+
// as a non-module sender and left the counter untouched (reusing a nonce).
123+
func TestModuleNonce_ModuleDeployUEAAdvancesModuleNonce(t *testing.T) {
124+
app, ctx, _, _, _, _ := setupInboundInitiatedOutboundTest(t, 4)
125+
k := app.UexecutorKeeper
126+
moduleAddr, _ := k.GetUeModuleAddress(ctx)
127+
128+
before, err := k.GetModuleAccountNonce(ctx)
129+
require.NoError(t, err)
130+
131+
// Deploy a UEA for a fresh owner AS THE MODULE (as the inbound path does).
132+
freshUA := &uexecutortypes.UniversalAccountId{
133+
ChainNamespace: "eip155", ChainId: "11155111",
134+
Owner: "0x00000000000000000000000000000000000000Ab",
135+
}
136+
_, _ = k.DeployUEAV2(ctx, moduleAddr, freshUA)
137+
138+
after, err := k.GetModuleAccountNonce(ctx)
139+
require.NoError(t, err)
140+
require.Equal(t, before+1, after, "a module-sender UEA deployment must advance ModuleAccountNonce by 1")
141+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package integrationtest
2+
3+
import (
4+
"encoding/hex"
5+
"math/big"
6+
"strings"
7+
"testing"
8+
9+
sdk "github.com/cosmos/cosmos-sdk/types"
10+
stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types"
11+
"github.com/ethereum/go-ethereum/accounts/abi"
12+
"github.com/ethereum/go-ethereum/common"
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/pushchain/push-chain-node/app"
16+
utils "github.com/pushchain/push-chain-node/test/utils"
17+
uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types"
18+
)
19+
20+
// pc20InboundWrapper is an external PC20 wrapper address that Push never mapped —
21+
// so getPC20Source (on the un-wired UniversalCore) can't resolve a source for it.
22+
var pc20InboundWrapper = common.HexToAddress("0x000000000000000000000000000000000000face")
23+
24+
// encodePC20UserPayload ABI-encodes a minimal (but decodable) UniversalPayload,
25+
// the "user payload" that rides behind the PC20 selector on a return.
26+
func encodePC20UserPayload(t *testing.T, to common.Address) string {
27+
t.Helper()
28+
tupleType, err := abi.NewType("tuple", "", []abi.ArgumentMarshaling{
29+
{Name: "to", Type: "address"},
30+
{Name: "value", Type: "uint256"},
31+
{Name: "data", Type: "bytes"},
32+
{Name: "gasLimit", Type: "uint256"},
33+
{Name: "maxFeePerGas", Type: "uint256"},
34+
{Name: "maxPriorityFeePerGas", Type: "uint256"},
35+
{Name: "nonce", Type: "uint256"},
36+
{Name: "deadline", Type: "uint256"},
37+
{Name: "vType", Type: "uint8"},
38+
})
39+
require.NoError(t, err)
40+
41+
type up struct {
42+
To common.Address
43+
Value *big.Int
44+
Data []byte
45+
GasLimit *big.Int
46+
MaxFeePerGas *big.Int
47+
MaxPriorityFeePerGas *big.Int
48+
Nonce *big.Int
49+
Deadline *big.Int
50+
VType uint8
51+
}
52+
packed, err := abi.Arguments{{Type: tupleType}}.Pack(up{
53+
To: to,
54+
Value: big.NewInt(0),
55+
Data: []byte{},
56+
GasLimit: big.NewInt(21000),
57+
MaxFeePerGas: big.NewInt(1000000000),
58+
MaxPriorityFeePerGas: big.NewInt(200000000),
59+
Nonce: big.NewInt(0),
60+
Deadline: big.NewInt(9999999999),
61+
VType: 1,
62+
})
63+
require.NoError(t, err)
64+
return "0x" + hex.EncodeToString(packed)
65+
}
66+
67+
// pc20SelectorPrefixed returns a raw inbound payload with the PC20 selector in
68+
// front of a decodable user payload — the shape the external gateway emits.
69+
func pc20SelectorPrefixed(t *testing.T) string {
70+
return "0x" + uexecutortypes.PC20Selector + strings.TrimPrefix(
71+
encodePC20UserPayload(t, common.HexToAddress("0x000000000000000000000000000000000000beef")), "0x")
72+
}
73+
74+
func votePC20InboundToQuorum(t *testing.T, ctx sdk.Context, chainApp *app.ChainApp, vals []string, coreVals []stakingtypes.Validator, inbound *uexecutortypes.Inbound) {
75+
t.Helper()
76+
for i := 0; i < 3; i++ {
77+
valAddr, err := sdk.ValAddressFromBech32(coreVals[i].OperatorAddress)
78+
require.NoError(t, err)
79+
require.NoError(t, utils.ExecVoteInbound(t, ctx, chainApp, vals[i], sdk.AccAddress(valAddr).String(), inbound))
80+
}
81+
}
82+
83+
func hasInboundRevert(utx uexecutortypes.UniversalTx) *uexecutortypes.OutboundTx {
84+
for _, ob := range utx.OutboundTx {
85+
if ob.TxType == uexecutortypes.TxType_INBOUND_REVERT {
86+
return ob
87+
}
88+
}
89+
return nil
90+
}
91+
92+
// TestPC20Inbound_Routing drives PC20 returns (external chain -> Push) end to end
93+
// through the vote/finalize/execute path. The PC20-aware UniversalCore
94+
// (getPC20Source) and VaultPC20 (unlock) are not wired into this harness, so the
95+
// funds step can't complete — but the routing decision (is_pc20 detection + strip,
96+
// unlock vs deposit, revert vs no-revert) is fully observable. The happy round-trip
97+
// (unlock releases funds, stripped payload executes) is gated on those contract
98+
// bytecodes and covered separately once they're deployed.
99+
func TestPC20Inbound_Routing(t *testing.T) {
100+
t.Run("non-CEA PC20 return routes to unlock and reverts when source is unresolvable", func(t *testing.T) {
101+
chainApp, ctx, vals, inbound, coreVals, _ := setupInboundInitiatedOutboundTest(t, 4)
102+
103+
inbound.AssetAddr = pc20InboundWrapper.Hex()
104+
inbound.IsCEA = false
105+
inbound.UniversalPayload = nil
106+
inbound.RawPayload = pc20SelectorPrefixed(t)
107+
108+
votePC20InboundToQuorum(t, ctx, chainApp, vals, coreVals, inbound)
109+
110+
utx, found, err := chainApp.UexecutorKeeper.GetUniversalTx(ctx, uexecutortypes.GetInboundUniversalTxKey(*inbound))
111+
require.NoError(t, err)
112+
require.True(t, found)
113+
114+
require.True(t, utx.InboundTx.IsPc20, "PC20 selector must be detected and flagged")
115+
116+
rev := hasInboundRevert(utx)
117+
require.NotNil(t, rev, "an unresolvable PC20 return must produce an INBOUND_REVERT (wrapper re-mint)")
118+
// The revert re-mints the wrapper: carries the wrapper as external asset and
119+
// must NOT be flagged is_pc20 (a failed INBOUND_REVERT must never route into
120+
// handleFailedOutbound's revertExport, which is only for a failed export).
121+
require.Equal(t, pc20InboundWrapper.Hex(), rev.ExternalAssetAddr)
122+
require.False(t, rev.IsPc20)
123+
})
124+
125+
t.Run("CEA PC20 return that can't resolve source does NOT revert", func(t *testing.T) {
126+
chainApp, ctx, vals, inbound, coreVals, ueaAddr := setupInboundInitiatedOutboundTest(t, 4)
127+
128+
// CEA inbound: recipient is explicit (a deployed UEA). CEA failures never
129+
// create an INBOUND_REVERT — the PC20 unlock failure must honour that.
130+
inbound.AssetAddr = pc20InboundWrapper.Hex()
131+
inbound.IsCEA = true
132+
inbound.Recipient = ueaAddr.Hex()
133+
inbound.UniversalPayload = nil
134+
inbound.RawPayload = pc20SelectorPrefixed(t)
135+
136+
votePC20InboundToQuorum(t, ctx, chainApp, vals, coreVals, inbound)
137+
138+
utx, found, err := chainApp.UexecutorKeeper.GetUniversalTx(ctx, uexecutortypes.GetInboundUniversalTxKey(*inbound))
139+
require.NoError(t, err)
140+
require.True(t, found)
141+
142+
require.True(t, utx.InboundTx.IsPc20, "PC20 selector must be detected even on a CEA return")
143+
require.Nil(t, hasInboundRevert(utx), "a CEA PC20 return must NOT create an INBOUND_REVERT")
144+
})
145+
146+
t.Run("un-prefixed (PRC20) inbound is not flagged pc20 and does not revert", func(t *testing.T) {
147+
chainApp, ctx, vals, inbound, coreVals, _ := setupInboundInitiatedOutboundTest(t, 4)
148+
149+
// A raw payload with no PC20 selector: the tolerant router leaves it alone,
150+
// is_pc20 stays false, and the funds step takes the PRC20 deposit path
151+
// (registered token from setup) — diverging from the PC20 return above.
152+
inbound.UniversalPayload = nil
153+
inbound.RawPayload = encodePC20UserPayload(t, common.HexToAddress("0x000000000000000000000000000000000000beef"))
154+
155+
votePC20InboundToQuorum(t, ctx, chainApp, vals, coreVals, inbound)
156+
157+
utx, found, err := chainApp.UexecutorKeeper.GetUniversalTx(ctx, uexecutortypes.GetInboundUniversalTxKey(*inbound))
158+
require.NoError(t, err)
159+
require.True(t, found)
160+
161+
require.False(t, utx.InboundTx.IsPc20, "an un-prefixed payload must not be flagged pc20")
162+
require.Nil(t, hasInboundRevert(utx), "PRC20 deposit path should complete without an INBOUND_REVERT")
163+
})
164+
}

0 commit comments

Comments
 (0)