diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fc5413..4e7850e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,8 @@ name: CI on: push: - branches: [ main, master ] + branches: [ main ] pull_request: - branches: [ main, master ] schedule: - cron: '0 */6 * * *' # Every 6 hours - catch tempo drift early workflow_dispatch: # Manual trigger @@ -80,8 +79,8 @@ jobs: docker compose logs exit 1 - - name: Run integration tests - run: make integration + - name: Run integration tests (offline only - localnet lacks tempo_fundAddress) + run: go test -v -run "TestIntegration_NodeConnection|TestIntegration_BuilderValidation|TestIntegration_RoundTrip" -timeout=5m ./tests env: TEMPO_RPC_URL: http://localhost:8545 @@ -97,7 +96,6 @@ jobs: name: Integration Tests (Devnet) runs-on: ubuntu-latest needs: test - if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main') steps: - name: Checkout code @@ -112,7 +110,7 @@ jobs: run: go mod download - name: Run integration tests against devnet - run: make integration + run: go test -v -run TestIntegration -timeout=10m ./tests env: TEMPO_RPC_URL: ${{ secrets.DEVNET_RPC_URL }} @@ -120,7 +118,6 @@ jobs: name: Integration Tests (Testnet) runs-on: ubuntu-latest needs: test - if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main') steps: - name: Checkout code @@ -135,6 +132,6 @@ jobs: run: go mod download - name: Run integration tests against testnet - run: make integration + run: go test -v -run TestIntegration -timeout=10m ./tests env: TEMPO_RPC_URL: ${{ secrets.TESTNET_RPC_URL }} diff --git a/go.mod b/go.mod index f267554..2a637b6 100644 --- a/go.mod +++ b/go.mod @@ -10,12 +10,19 @@ require ( require ( github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect + github.com/consensys/gnark-crypto v0.18.0 // indirect + github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect + github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect + github.com/ethereum/go-verkle v0.2.2 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect golang.org/x/crypto v0.36.0 // indirect + golang.org/x/sync v0.12.0 // indirect golang.org/x/sys v0.36.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 58d453e..75c649a 100644 --- a/go.sum +++ b/go.sum @@ -1,36 +1,85 @@ github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/consensys/gnark-crypto v0.18.0 h1:vIye/FqI50VeAr0B3dx+YjeIvmc3LWz4yEfbWBpTUf0= +github.com/consensys/gnark-crypto v0.18.0/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= +github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= +github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= +github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= +github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= github.com/ethereum/go-ethereum v1.16.8 h1:LLLfkZWijhR5m6yrAXbdlTeXoqontH+Ga2f9igY7law= github.com/ethereum/go-ethereum v1.16.8/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk= +github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= +github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= +github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw= +github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/keychain/doc.go b/pkg/keychain/doc.go new file mode 100644 index 0000000..042caed --- /dev/null +++ b/pkg/keychain/doc.go @@ -0,0 +1,39 @@ +// Package keychain provides access key management and signing for Tempo transactions. +// +// The AccountKeychain precompile manages authorized Access Keys for accounts, +// enabling Root Keys (e.g., passkeys) to provision scoped "secondary" Access Keys +// with expiry timestamps and per-TIP20 token spending limits. +// +// # Keychain Signature Format +// +// Per Tempo spec, Keychain signatures have format: +// +// 0x03 || root_account (20 bytes) || inner_signature (65 bytes) +// +// Where: +// - 0x03 is the Keychain signature type identifier +// - root_account is the account the access key signs on behalf of +// - inner_signature is the secp256k1 signature from the access key (r || s || v) +// +// Total signature length: 86 bytes +// +// # Example Usage +// +// // Create a transaction +// tx := transaction.NewTx() +// tx.ChainID = big.NewInt(42431) +// tx.Calls = []transaction.Call{{To: &recipient, Value: big.NewInt(1000000)}} +// +// // Sign with access key instead of root key +// accessKeySigner, _ := signer.NewSigner(accessKeyPrivateKey) +// err := keychain.SignWithAccessKey(tx, accessKeySigner, rootAccount) +// +// # AccountKeychain Precompile +// +// The precompile is located at address 0xAAAAAAAA00000000000000000000000000000000. +// It provides functions for: +// - authorizeKey: Authorize a new access key with optional spending limits +// - revokeKey: Revoke an access key +// - updateSpendingLimit: Update spending limit for a token +// - getRemainingLimit: Query remaining spending limit +package keychain diff --git a/pkg/keychain/helpers.go b/pkg/keychain/helpers.go new file mode 100644 index 0000000..5aea8ed --- /dev/null +++ b/pkg/keychain/helpers.go @@ -0,0 +1,91 @@ +package keychain + +import ( + "encoding/hex" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/common" +) + +// GetRemainingLimitSelector is the function selector for getRemainingLimit(address,address,address). +const GetRemainingLimitSelector = "0x63b4290d" + +// byteSliceToBigInt converts a byte slice to a big.Int. +func byteSliceToBigInt(b []byte) *big.Int { + return new(big.Int).SetBytes(b) +} + +// EncodeGetRemainingLimitCalldata encodes the calldata for getRemainingLimit(address,address,address). +// +// Parameters: +// - accountAddress: The root wallet address +// - keyID: The access key ID (address) +// - tokenAddress: The token to check limit for +// +// Returns the hex-encoded calldata (with 0x prefix). +func EncodeGetRemainingLimitCalldata(accountAddress, keyID, tokenAddress common.Address) string { + accountPadded := padAddress(accountAddress) + keyPadded := padAddress(keyID) + tokenPadded := padAddress(tokenAddress) + + return GetRemainingLimitSelector + accountPadded + keyPadded + tokenPadded +} + +// padAddress pads an address to 32 bytes (64 hex chars) for ABI encoding. +func padAddress(addr common.Address) string { + return strings.Repeat("0", 24) + strings.ToLower(hex.EncodeToString(addr.Bytes())) +} + +// ParseRemainingLimitResult parses the result of a getRemainingLimit call. +// +// The result is a 32-byte big-endian uint256. +func ParseRemainingLimitResult(result []byte) *big.Int { + if len(result) == 0 { + return big.NewInt(0) + } + return new(big.Int).SetBytes(result) +} + +// IsKeychainSignature checks if the given signature bytes represent a Keychain signature. +func IsKeychainSignature(sig []byte) bool { + return len(sig) == KeychainSignatureLength && sig[0] == KeychainSignatureType +} + +// GetKeychainAddress returns the AccountKeychain precompile address. +func GetKeychainAddress() common.Address { + return common.HexToAddress(AccountKeychainAddress) +} + +// AuthorizeKeySelector is the function selector for authorizeKey. +const AuthorizeKeySelector = "0x..." // TODO: Add actual selector + +// RevokeKeySelector is the function selector for revokeKey. +const RevokeKeySelector = "0x..." // TODO: Add actual selector + +// SpendingLimit represents a per-token spending limit for an access key. +type SpendingLimit struct { + Token common.Address + Amount *big.Int +} + +// AuthorizedKey represents an authorized access key with its configuration. +type AuthorizedKey struct { + KeyID common.Address + SignatureType uint8 // 0 = Secp256k1, 1 = P256, 2 = WebAuthn + Expiry uint64 + EnforceLimits bool + IsRevoked bool +} + +// ValidateAccessKeySignature is a helper that validates a signature is a proper keychain signature. +func ValidateAccessKeySignature(sig []byte) error { + if len(sig) != KeychainSignatureLength { + return fmt.Errorf("invalid signature length: expected %d, got %d", KeychainSignatureLength, len(sig)) + } + if sig[0] != KeychainSignatureType { + return fmt.Errorf("invalid signature type: expected 0x%02x, got 0x%02x", KeychainSignatureType, sig[0]) + } + return nil +} diff --git a/pkg/keychain/keychain.go b/pkg/keychain/keychain.go new file mode 100644 index 0000000..f7d666c --- /dev/null +++ b/pkg/keychain/keychain.go @@ -0,0 +1,173 @@ +package keychain + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/tempoxyz/tempo-go/pkg/signer" + "github.com/tempoxyz/tempo-go/pkg/transaction" +) + +const ( + // KeychainSignatureType is the signature type identifier for Keychain signatures. + KeychainSignatureType = 0x03 + + // InnerSignatureLength is the length of the inner secp256k1 signature (r + s + v). + InnerSignatureLength = 65 + + // KeychainSignatureLength is the total length of a Keychain signature. + // Format: type (1) + root_account (20) + inner_signature (65) = 86 bytes. + KeychainSignatureLength = 86 + + // AccountKeychainAddress is the address of the AccountKeychain precompile. + AccountKeychainAddress = "0xAAAAAAAA00000000000000000000000000000000" +) + +// BuildKeychainSignature creates a Keychain signature from an inner secp256k1 signature. +// +// The Keychain signature format is: +// +// 0x03 || root_account (20 bytes) || inner_signature (65 bytes) +// +// Parameters: +// - innerSig: The secp256k1 signature from the access key +// - rootAccount: The address of the root account (the account the access key acts on behalf of) +// +// Returns the 86-byte Keychain signature. +func BuildKeychainSignature(innerSig *signer.Signature, rootAccount common.Address) []byte { + result := make([]byte, KeychainSignatureLength) + + // Byte 0: Keychain signature type (0x03) + result[0] = KeychainSignatureType + + // Bytes 1-20: Root account address + copy(result[1:21], rootAccount.Bytes()) + + // Bytes 21-85: Inner signature (r || s || v) + // R: 32 bytes + rBytes := innerSig.R.Bytes() + copy(result[21+(32-len(rBytes)):53], rBytes) + + // S: 32 bytes + sBytes := innerSig.S.Bytes() + copy(result[53+(32-len(sBytes)):85], sBytes) + + // V: 1 byte (yParity) + result[85] = innerSig.YParity + + return result +} + +// SignWithAccessKey signs a Tempo transaction using an access key. +// +// This creates a Keychain signature that allows the access key to sign +// transactions on behalf of the root account. +// +// The function: +// 1. Sets tx.From to the root account address +// 2. Computes the signing hash +// 3. Signs with the access key's private key +// 4. Wraps the signature in Keychain format (0x03 || root || inner_sig) +// +// Parameters: +// - tx: The transaction to sign (will be modified in place) +// - accessKeySigner: The signer for the access key +// - rootAccount: The address of the root account +// +// Returns an error if signing fails. +func SignWithAccessKey(tx *transaction.Tx, accessKeySigner *signer.Signer, rootAccount common.Address) error { + // Validate transaction before signing + if err := tx.Validate(); err != nil { + return err + } + + // Set the from address to the root account BEFORE computing signing hash + tx.From = rootAccount + + // Get the signing hash + hash, err := transaction.GetSignPayload(tx) + if err != nil { + return fmt.Errorf("failed to get sign payload: %w", err) + } + + // Sign with the access key + innerSig, err := accessKeySigner.Sign(hash) + if err != nil { + return fmt.Errorf("failed to sign with access key: %w", err) + } + + // Build the Keychain signature + keychainSig := BuildKeychainSignature(innerSig, rootAccount) + + // Create a signature envelope with the raw Keychain signature bytes + tx.Signature = &signer.SignatureEnvelope{ + Type: "keychain", + Raw: keychainSig, + } + + return nil +} + +// ParseKeychainSignature parses a Keychain signature into its components. +// +// Returns the signature type, root account address, and inner signature. +// Returns an error if the signature is invalid. +func ParseKeychainSignature(sig []byte) (sigType byte, rootAccount common.Address, innerSig *signer.Signature, err error) { + if len(sig) != KeychainSignatureLength { + return 0, common.Address{}, nil, fmt.Errorf("invalid keychain signature length: expected %d, got %d", KeychainSignatureLength, len(sig)) + } + + sigType = sig[0] + if sigType != KeychainSignatureType { + return 0, common.Address{}, nil, fmt.Errorf("invalid keychain signature type: expected 0x%02x, got 0x%02x", KeychainSignatureType, sigType) + } + + rootAccount = common.BytesToAddress(sig[1:21]) + + innerSig = signer.NewSignature( + byteSliceToBigInt(sig[21:53]), // R + byteSliceToBigInt(sig[53:85]), // S + sig[85], // YParity + ) + + return sigType, rootAccount, innerSig, nil +} + +// VerifyAccessKeySignature verifies that a Keychain signature was created by a valid access key. +// +// Parameters: +// - tx: The transaction with the Keychain signature +// +// Returns the access key address (the signer) and root account address if valid. +func VerifyAccessKeySignature(tx *transaction.Tx) (accessKeyAddr, rootAccount common.Address, err error) { + if tx.Signature == nil { + return common.Address{}, common.Address{}, fmt.Errorf("transaction has no signature") + } + + if tx.Signature.Type != "keychain" || tx.Signature.Raw == nil { + return common.Address{}, common.Address{}, fmt.Errorf("signature is not a keychain signature") + } + + _, rootAccount, innerSig, err := ParseKeychainSignature(tx.Signature.Raw) + if err != nil { + return common.Address{}, common.Address{}, fmt.Errorf("failed to parse keychain signature: %w", err) + } + + // Get the signing hash (with From set to root account) + txCopy := *tx + txCopy.From = rootAccount + txCopy.Signature = nil + + hash, err := transaction.GetSignPayload(&txCopy) + if err != nil { + return common.Address{}, common.Address{}, fmt.Errorf("failed to get sign payload: %w", err) + } + + // Recover the access key address from the inner signature + accessKeyAddr, err = signer.RecoverAddress(hash, innerSig) + if err != nil { + return common.Address{}, common.Address{}, fmt.Errorf("failed to recover access key address: %w", err) + } + + return accessKeyAddr, rootAccount, nil +} diff --git a/pkg/keychain/keychain_test.go b/pkg/keychain/keychain_test.go new file mode 100644 index 0000000..6b7bb47 --- /dev/null +++ b/pkg/keychain/keychain_test.go @@ -0,0 +1,279 @@ +package keychain + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/tempoxyz/tempo-go/pkg/signer" + "github.com/tempoxyz/tempo-go/pkg/transaction" +) + +// Test private keys (DO NOT USE IN PRODUCTION) +const ( + rootKeyPrivate = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + accessKeyPrivate = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" +) + +func TestBuildKeychainSignature(t *testing.T) { + // Create a test signature + r := new(big.Int) + r.SetString("12345678901234567890123456789012345678901234567890", 10) + s := new(big.Int) + s.SetString("98765432109876543210987654321098765432109876543210", 10) + yParity := uint8(0) + + innerSig := signer.NewSignature(r, s, yParity) + rootAccount := common.HexToAddress("0x70997970C51812dc3A010C7d01b50e0d17dc79C8") + + keychainSig := BuildKeychainSignature(innerSig, rootAccount) + + // Verify length + if len(keychainSig) != KeychainSignatureLength { + t.Errorf("expected length %d, got %d", KeychainSignatureLength, len(keychainSig)) + } + + // Verify type byte + if keychainSig[0] != KeychainSignatureType { + t.Errorf("expected type 0x%02x, got 0x%02x", KeychainSignatureType, keychainSig[0]) + } + + // Verify root account + recoveredRoot := common.BytesToAddress(keychainSig[1:21]) + if recoveredRoot != rootAccount { + t.Errorf("expected root account %s, got %s", rootAccount.Hex(), recoveredRoot.Hex()) + } + + // Verify yParity + if keychainSig[85] != yParity { + t.Errorf("expected yParity %d, got %d", yParity, keychainSig[85]) + } +} + +func TestParseKeychainSignature(t *testing.T) { + // Build a signature first + r := new(big.Int) + r.SetString("12345678901234567890123456789012345678901234567890", 10) + s := new(big.Int) + s.SetString("98765432109876543210987654321098765432109876543210", 10) + yParity := uint8(1) + + innerSig := signer.NewSignature(r, s, yParity) + rootAccount := common.HexToAddress("0x70997970C51812dc3A010C7d01b50e0d17dc79C8") + + keychainSig := BuildKeychainSignature(innerSig, rootAccount) + + // Parse it back + sigType, parsedRoot, parsedInner, err := ParseKeychainSignature(keychainSig) + if err != nil { + t.Fatalf("failed to parse keychain signature: %v", err) + } + + if sigType != KeychainSignatureType { + t.Errorf("expected type 0x%02x, got 0x%02x", KeychainSignatureType, sigType) + } + + if parsedRoot != rootAccount { + t.Errorf("expected root %s, got %s", rootAccount.Hex(), parsedRoot.Hex()) + } + + if parsedInner.R.Cmp(r) != 0 { + t.Errorf("R mismatch: expected %s, got %s", r.String(), parsedInner.R.String()) + } + + if parsedInner.S.Cmp(s) != 0 { + t.Errorf("S mismatch: expected %s, got %s", s.String(), parsedInner.S.String()) + } + + if parsedInner.YParity != yParity { + t.Errorf("yParity mismatch: expected %d, got %d", yParity, parsedInner.YParity) + } +} + +func TestParseKeychainSignature_InvalidLength(t *testing.T) { + _, _, _, err := ParseKeychainSignature([]byte{0x03, 0x01, 0x02}) + if err == nil { + t.Error("expected error for invalid length") + } +} + +func TestParseKeychainSignature_InvalidType(t *testing.T) { + sig := make([]byte, KeychainSignatureLength) + sig[0] = 0x01 // Wrong type + + _, _, _, err := ParseKeychainSignature(sig) + if err == nil { + t.Error("expected error for invalid type") + } +} + +func TestSignWithAccessKey(t *testing.T) { + // Create signers + rootSigner, err := signer.NewSigner(rootKeyPrivate) + if err != nil { + t.Fatalf("failed to create root signer: %v", err) + } + + accessKeySigner, err := signer.NewSigner(accessKeyPrivate) + if err != nil { + t.Fatalf("failed to create access key signer: %v", err) + } + + rootAccount := rootSigner.Address() + + // Create a transaction + recipient := common.HexToAddress("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC") + tx := transaction.New() + tx.ChainID = big.NewInt(42431) + tx.Gas = 21000 + tx.MaxFeePerGas = big.NewInt(1000000000) + tx.MaxPriorityFeePerGas = big.NewInt(1000000000) + tx.Calls = []transaction.Call{ + {To: &recipient, Value: big.NewInt(1000000)}, + } + + // Sign with access key + err = SignWithAccessKey(tx, accessKeySigner, rootAccount) + if err != nil { + t.Fatalf("failed to sign with access key: %v", err) + } + + // Verify the signature is a keychain signature + if tx.Signature == nil { + t.Fatal("signature is nil") + } + + if tx.Signature.Type != "keychain" { + t.Errorf("expected signature type 'keychain', got '%s'", tx.Signature.Type) + } + + if tx.Signature.Raw == nil { + t.Fatal("raw signature is nil") + } + + if len(tx.Signature.Raw) != KeychainSignatureLength { + t.Errorf("expected raw signature length %d, got %d", KeychainSignatureLength, len(tx.Signature.Raw)) + } + + // Verify the from address is set to root account + if tx.From != rootAccount { + t.Errorf("expected from %s, got %s", rootAccount.Hex(), tx.From.Hex()) + } +} + +func TestVerifyAccessKeySignature(t *testing.T) { + // Create signers + rootSigner, err := signer.NewSigner(rootKeyPrivate) + if err != nil { + t.Fatalf("failed to create root signer: %v", err) + } + + accessKeySigner, err := signer.NewSigner(accessKeyPrivate) + if err != nil { + t.Fatalf("failed to create access key signer: %v", err) + } + + rootAccount := rootSigner.Address() + expectedAccessKeyAddr := accessKeySigner.Address() + + // Create and sign a transaction + recipient := common.HexToAddress("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC") + tx := transaction.New() + tx.ChainID = big.NewInt(42431) + tx.Gas = 21000 + tx.MaxFeePerGas = big.NewInt(1000000000) + tx.MaxPriorityFeePerGas = big.NewInt(1000000000) + tx.Calls = []transaction.Call{ + {To: &recipient, Value: big.NewInt(1000000)}, + } + + err = SignWithAccessKey(tx, accessKeySigner, rootAccount) + if err != nil { + t.Fatalf("failed to sign with access key: %v", err) + } + + // Verify the signature + accessKeyAddr, recoveredRoot, err := VerifyAccessKeySignature(tx) + if err != nil { + t.Fatalf("failed to verify access key signature: %v", err) + } + + if accessKeyAddr != expectedAccessKeyAddr { + t.Errorf("expected access key address %s, got %s", expectedAccessKeyAddr.Hex(), accessKeyAddr.Hex()) + } + + if recoveredRoot != rootAccount { + t.Errorf("expected root account %s, got %s", rootAccount.Hex(), recoveredRoot.Hex()) + } +} + +func TestIsKeychainSignature(t *testing.T) { + // Valid keychain signature + validSig := make([]byte, KeychainSignatureLength) + validSig[0] = KeychainSignatureType + if !IsKeychainSignature(validSig) { + t.Error("expected true for valid keychain signature") + } + + // Wrong type + wrongType := make([]byte, KeychainSignatureLength) + wrongType[0] = 0x01 + if IsKeychainSignature(wrongType) { + t.Error("expected false for wrong type") + } + + // Wrong length + wrongLength := make([]byte, 65) + wrongLength[0] = KeychainSignatureType + if IsKeychainSignature(wrongLength) { + t.Error("expected false for wrong length") + } +} + +func TestGetKeychainAddress(t *testing.T) { + addr := GetKeychainAddress() + expected := common.HexToAddress(AccountKeychainAddress) + if addr != expected { + t.Errorf("expected %s, got %s", expected.Hex(), addr.Hex()) + } +} + +func TestEncodeGetRemainingLimitCalldata(t *testing.T) { + account := common.HexToAddress("0x70997970C51812dc3A010C7d01b50e0d17dc79C8") + keyID := common.HexToAddress("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC") + token := common.HexToAddress("0x90F79bf6EB2c4f870365E785982E1f101E93b906") + + calldata := EncodeGetRemainingLimitCalldata(account, keyID, token) + + // Should start with the selector + if calldata[:10] != GetRemainingLimitSelector { + t.Errorf("expected selector %s, got %s", GetRemainingLimitSelector, calldata[:10]) + } + + // Should be 10 (selector) + 64*3 (three addresses) = 202 chars + expectedLen := 10 + 64*3 + if len(calldata) != expectedLen { + t.Errorf("expected length %d, got %d", expectedLen, len(calldata)) + } +} + +func TestValidateAccessKeySignature(t *testing.T) { + // Valid signature + valid := make([]byte, KeychainSignatureLength) + valid[0] = KeychainSignatureType + if err := ValidateAccessKeySignature(valid); err != nil { + t.Errorf("unexpected error for valid signature: %v", err) + } + + // Invalid length + if err := ValidateAccessKeySignature([]byte{0x03}); err == nil { + t.Error("expected error for invalid length") + } + + // Invalid type + invalid := make([]byte, KeychainSignatureLength) + invalid[0] = 0x01 + if err := ValidateAccessKeySignature(invalid); err == nil { + t.Error("expected error for invalid type") + } +} diff --git a/pkg/signer/signer.go b/pkg/signer/signer.go index 8667cb7..bd8a8fe 100644 --- a/pkg/signer/signer.go +++ b/pkg/signer/signer.go @@ -139,10 +139,11 @@ func (s *Signature) V() uint8 { } // SignatureEnvelope wraps a signature with its type. -// Supports secp256k1, p256, and webauthn signatures. +// Supports secp256k1, p256, webauthn, and keychain signatures. type SignatureEnvelope struct { - Type string `json:"type"` // "secp256k1", "p256", or "webauthn" - Signature *Signature `json:"signature"` // The actual signature + Type string `json:"type"` // "secp256k1", "p256", "webauthn", or "keychain" + Signature *Signature `json:"signature"` // The actual signature (for secp256k1, p256, webauthn) + Raw []byte `json:"raw"` // Raw signature bytes (for keychain signatures) } // NewSignature creates a new ECDSA signature. diff --git a/pkg/transaction/serialize.go b/pkg/transaction/serialize.go index 5b2bf83..85cd67c 100644 --- a/pkg/transaction/serialize.go +++ b/pkg/transaction/serialize.go @@ -267,7 +267,17 @@ func encodeSignature(sig *signer.Signature) []interface{} { // encodeSignatureEnvelope encodes a signature envelope to RLP. func encodeSignatureEnvelope(envelope *signer.SignatureEnvelope) ([]byte, error) { - if envelope == nil || envelope.Signature == nil { + if envelope == nil { + return []byte{}, nil + } + + // Handle keychain signatures (raw bytes) + if envelope.Type == "keychain" && envelope.Raw != nil { + return envelope.Raw, nil + } + + // For other types, we need a parsed signature + if envelope.Signature == nil { return []byte{}, nil } diff --git a/tests/integration_test.go b/tests/integration_test.go index 767ac30..b50a72f 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -2,56 +2,146 @@ package main import ( "context" + "encoding/hex" + "fmt" "math/big" "os" + "strings" "testing" "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tempoxyz/tempo-go/pkg/client" + "github.com/tempoxyz/tempo-go/pkg/keychain" "github.com/tempoxyz/tempo-go/pkg/signer" "github.com/tempoxyz/tempo-go/pkg/transaction" ) -const ( - // Anvil/Hardhat test account #0 - // Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 - testPrivateKey1 = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" - - // Anvil/Hardhat test account #1 - // Address: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 - testPrivateKey2 = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" - - // Anvil/Hardhat test account #2 - // Address: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC - feePayerPrivateKey = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" - - // AlphaUSD token address (for testnet) - alphaUSDAddress = "0x20c0000000000000000000000000000000000001" - - // Native token (use for local dev node which is pre-funded with native tokens) - nativeTokenAddress = "0x0000000000000000000000000000000000000000" +// Precompile and token addresses +var ( + // Native fee token (default) + nativeFeeToken = common.HexToAddress("0x20c0000000000000000000000000000000000000") + // AlphaUSD token address + alphaUSD = common.HexToAddress("0x20C0000000000000000000000000000000000001") + // BetaUSD token address + betaUSD = common.HexToAddress("0x20C0000000000000000000000000000000000002") + // ThetaUSD token address + thetaUSD = common.HexToAddress("0x20C0000000000000000000000000000000000003") + // Fee Controller precompile + feeController = common.HexToAddress("0xfeec000000000000000000000000000000000000") + // Account Keychain precompile + accountKeychain = common.HexToAddress("0xAAAAAAAA00000000000000000000000000000000") + // DEX precompile + dex = common.HexToAddress("0xdec0000000000000000000000000000000000000") + // Counter contract (deployed on testnet/devnet) + counterContract = common.HexToAddress("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D") + // LP Recipient for fee token liquidity + lpRecipient = common.HexToAddress("0x6c4143BEd3A13cf9E5E43d45C60aD816FC091d0c") ) +// Function selectors var ( - feeTokenAddress = alphaUSDAddress + // increment() selector + incrementSelector = mustDecodeHex("d09de08a") + // mint(address,address,uint256,address) selector + mintSelector = mustDecodeHex("f1aa8cb8") + // setUserToken(address) selector + setUserTokenSelector = mustDecodeHex("e7897444") + // authorizeKey(address,uint8,uint64,bool,(address,uint256)[]) selector + authorizeKeySelector = mustDecodeHex("54063a55") ) +func mustDecodeHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} + var ( rpcURL string chainID int64 ) func init() { - // Require TEMPO_RPC_URL to be set to a real Tempo node rpcURL = os.Getenv("TEMPO_RPC_URL") if rpcURL == "" { panic("TEMPO_RPC_URL environment variable must be set to run integration tests. Example: export TEMPO_RPC_URL=https://rpc.testnet.tempo.xyz") } } +// waitForReceipt waits for a transaction receipt with retries and returns it +func waitForReceipt(t *testing.T, rpcClient *client.Client, txHash string) map[string]interface{} { + t.Helper() + ctx := context.Background() + + for i := 0; i < 15; i++ { + time.Sleep(2 * time.Second) + resp, err := rpcClient.SendRequest(ctx, "eth_getTransactionReceipt", txHash) + if err == nil && resp.Result != nil { + if receipt, ok := resp.Result.(map[string]interface{}); ok { + return receipt + } + } + } + t.Logf("Warning: Receipt not available after 30 seconds for tx %s", txHash) + return nil +} + +// formatReceipt formats a transaction receipt for human-readable output (similar to cast) +func formatReceipt(t *testing.T, receipt map[string]interface{}) { + t.Helper() + if receipt == nil { + t.Logf("Receipt not available") + return + } + + status := "false" + if s, ok := receipt["status"].(string); ok && s == "0x1" { + status = "true" + } + + txType := "unknown" + if typeVal, ok := receipt["type"].(string); ok { + switch typeVal { + case "0x76": + txType = "Tempo (0x76)" + case "0x2": + txType = "EIP-1559" + case "0x0": + txType = "Legacy" + default: + txType = typeVal + } + } + + t.Logf("\n"+ + "status %s\n"+ + "transactionHash %s\n"+ + "type %s\n"+ + "from %v\n"+ + "to %v\n"+ + "feePayer %v\n"+ + "feeToken %v\n"+ + "gasUsed %v\n"+ + "effectiveGasPrice %v", + status, + receipt["transactionHash"], + txType, + receipt["from"], + receipt["to"], + receipt["feePayer"], + receipt["feeToken"], + receipt["gasUsed"], + receipt["effectiveGasPrice"], + ) +} + // getChainID fetches and caches the chain ID from the RPC node. func getChainID(t *testing.T, rpcClient *client.Client) int64 { t.Helper() @@ -68,217 +158,571 @@ func getChainID(t *testing.T, rpcClient *client.Client) int64 { return chainID } -// TestIntegration_SimpleTransaction tests creating, signing, and sending a simple transaction. -func TestIntegration_SimpleTransaction(t *testing.T) { +// fundAddress funds an address using the tempo_fundAddress RPC +func fundAddress(t *testing.T, rpcClient *client.Client, address common.Address) { + t.Helper() ctx := context.Background() - sender, err := signer.NewSigner(testPrivateKey1) - require.NoError(t, err) + for i := 0; i < 100; i++ { + resp, err := rpcClient.SendRequest(ctx, "tempo_fundAddress", address.Hex()) + if err == nil && resp.Error == nil { + if result, ok := resp.Result.([]interface{}); ok && len(result) > 0 { + t.Logf("Funded address %s", address.Hex()) + time.Sleep(5 * time.Second) // Wait for blocks to mine + return + } + } + time.Sleep(200 * time.Millisecond) + } + t.Logf("Warning: Failed to fund address %s after 100 attempts", address.Hex()) +} - recipient, err := signer.NewSigner(testPrivateKey2) +// createAndFundSigner creates a new signer and funds it +func createAndFundSigner(t *testing.T, rpcClient *client.Client) *signer.Signer { + t.Helper() + privateKey, err := crypto.GenerateKey() require.NoError(t, err) + s := signer.NewSignerFromKey(privateKey) + fundAddress(t, rpcClient, s.Address()) + return s +} - t.Logf("Sender address: %s", sender.Address().Hex()) - t.Logf("Recipient address: %s", recipient.Address().Hex()) +// encodeCalldata encodes function selector + arguments +func encodeCalldata(selector []byte, args ...[]byte) []byte { + result := make([]byte, len(selector)) + copy(result, selector) + for _, arg := range args { + result = append(result, arg...) + } + return result +} + +// padLeft32 pads a byte slice to 32 bytes on the left +func padLeft32(b []byte) []byte { + if len(b) >= 32 { + return b[:32] + } + result := make([]byte, 32) + copy(result[32-len(b):], b) + return result +} + +// addressToBytes32 converts an address to a 32-byte array +func addressToBytes32(addr common.Address) []byte { + return padLeft32(addr.Bytes()) +} +// uint256ToBytes32 converts a big.Int to a 32-byte array +func uint256ToBytes32(n *big.Int) []byte { + return padLeft32(n.Bytes()) +} + +// getGasPrice fetches the current gas price from the network +func getGasPrice(t *testing.T, rpcClient *client.Client) *big.Int { + t.Helper() + ctx := context.Background() + resp, err := rpcClient.SendRequest(ctx, "eth_gasPrice") + if err != nil { + // Fallback to high gas price + return big.NewInt(50000000000) // 50 gwei + } + if resp.Error != nil { + return big.NewInt(50000000000) + } + gasPriceHex, ok := resp.Result.(string) + if !ok { + return big.NewInt(50000000000) + } + gasPrice := new(big.Int) + gasPrice.SetString(strings.TrimPrefix(gasPriceHex, "0x"), 16) + // Add 50% buffer for priority + gasPrice.Mul(gasPrice, big.NewInt(3)) + gasPrice.Div(gasPrice, big.NewInt(2)) + return gasPrice +} + +// TestIntegration_NodeConnection tests basic node connectivity +func TestIntegration_NodeConnection(t *testing.T) { + ctx := context.Background() + rpcClient := client.New(rpcURL) + + t.Run("GetBlockNumber", func(t *testing.T) { + blockNum, err := rpcClient.GetBlockNumber(ctx) + require.NoError(t, err) + // Dev node may have block 0, testnet/devnet will have higher + assert.GreaterOrEqual(t, blockNum, uint64(0)) + t.Logf("Current block number: %d", blockNum) + }) + + t.Run("GetChainID", func(t *testing.T) { + chainID, err := rpcClient.GetChainID(ctx) + require.NoError(t, err) + assert.Greater(t, chainID, uint64(0)) + t.Logf("Chain ID: %d", chainID) + }) + + t.Run("ClientVersion", func(t *testing.T) { + resp, err := rpcClient.SendRequest(ctx, "web3_clientVersion") + require.NoError(t, err) + require.NoError(t, resp.CheckError()) + version, ok := resp.Result.(string) + require.True(t, ok) + assert.NotEmpty(t, version) + t.Logf("Client version: %s", version) + }) +} + +// TestIntegration_SimpleTransaction tests creating, signing, and sending a simple transaction +func TestIntegration_SimpleTransaction(t *testing.T) { + ctx := context.Background() rpcClient := client.New(rpcURL) cid := getChainID(t, rpcClient) + gasPrice := getGasPrice(t, rpcClient) - // Get initial block number to verify node is running - blockNum, err := rpcClient.GetBlockNumber(ctx) - require.NoError(t, err) - t.Logf("Current block number: %d", blockNum) + sender := createAndFundSigner(t, rpcClient) + t.Logf("Sender address: %s", sender.Address().Hex()) nonce, err := rpcClient.GetTransactionCount(ctx, sender.Address().Hex()) require.NoError(t, err) - t.Logf("Sender nonce: %d", nonce) tx := transaction.NewBuilder(big.NewInt(cid)). SetNonce(nonce). - SetGas(100000). - SetMaxFeePerGas(big.NewInt(10000000000)). - SetMaxPriorityFeePerGas(big.NewInt(10000000000)). - SetFeeToken(common.HexToAddress(feeTokenAddress)). - AddCall( - recipient.Address(), - big.NewInt(0), // Send 0 value for testing - []byte{}, - ). + SetGas(300000). + SetMaxFeePerGas(gasPrice). + SetMaxPriorityFeePerGas(gasPrice). + AddCall(counterContract, big.NewInt(0), incrementSelector). Build() err = transaction.SignTransaction(tx, sender) require.NoError(t, err) - assert.NotNil(t, tx.Signature, "Transaction should be signed") - - recoveredAddr, err := transaction.VerifySignature(tx) - require.NoError(t, err) - assert.Equal(t, sender.Address(), recoveredAddr, "Recovered address should match sender") serialized, err := transaction.Serialize(tx, nil) require.NoError(t, err) - assert.True(t, len(serialized) > 0, "Serialized transaction should not be empty") - t.Logf("Serialized transaction: %s...", serialized[:50]) txHash, err := rpcClient.SendRawTransaction(ctx, serialized) require.NoError(t, err) - assert.True(t, len(txHash) > 0, "Transaction hash should not be empty") t.Logf("Transaction hash: %s", txHash) - time.Sleep(2 * time.Second) - - newBlockNum, err := rpcClient.GetBlockNumber(ctx) - require.NoError(t, err) - assert.GreaterOrEqual(t, newBlockNum, blockNum, "Block number should have increased") - t.Logf("New block number: %d", newBlockNum) + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "Transaction failed") + formatReceipt(t, receipt) } -// TestIntegration_BuilderValidation tests the BuildAndValidate method. -func TestIntegration_BuilderValidation(t *testing.T) { +// TestIntegration_FeeTokenLiquidity tests adding fee token liquidity +func TestIntegration_FeeTokenLiquidity(t *testing.T) { + ctx := context.Background() rpcClient := client.New(rpcURL) cid := getChainID(t, rpcClient) + gasPrice := getGasPrice(t, rpcClient) + + sender := createAndFundSigner(t, rpcClient) + + // Add liquidity for each fee token: AlphaUSD, BetaUSD, ThetaUSD + feeTokens := []struct { + name string + token common.Address + }{ + {"AlphaUSD", alphaUSD}, + {"BetaUSD", betaUSD}, + {"ThetaUSD", thetaUSD}, + } - recipient := common.HexToAddress("0x1234567890123456789012345678901234567890") + for _, ft := range feeTokens { + t.Run(ft.name, func(t *testing.T) { + nonce, err := rpcClient.GetTransactionCount(ctx, sender.Address().Hex()) + require.NoError(t, err) + + // mint(address token, address feeToken, uint256 amount, address recipient) + calldata := encodeCalldata( + mintSelector, + addressToBytes32(ft.token), + addressToBytes32(nativeFeeToken), + uint256ToBytes32(big.NewInt(1000000000)), + addressToBytes32(lpRecipient), + ) + + tx := transaction.NewBuilder(big.NewInt(cid)). + SetNonce(nonce). + SetGas(500000). + SetMaxFeePerGas(gasPrice). + SetMaxPriorityFeePerGas(gasPrice). + AddCall(feeController, big.NewInt(0), calldata). + Build() + + err = transaction.SignTransaction(tx, sender) + require.NoError(t, err) + + serialized, err := transaction.Serialize(tx, nil) + require.NoError(t, err) + + txHash, err := rpcClient.SendRawTransaction(ctx, serialized) + require.NoError(t, err) + t.Logf("%s liquidity tx hash: %s", ft.name, txHash) + + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "Transaction failed") + formatReceipt(t, receipt) + }) + } +} - tx, err := transaction.NewBuilder(big.NewInt(cid)). - SetGas(100000). - SetFeeToken(common.HexToAddress(feeTokenAddress)). - AddCall(recipient, big.NewInt(0), []byte{}). - BuildAndValidate() +// TestIntegration_SendWithFeeToken tests sending transactions with custom fee tokens +func TestIntegration_SendWithFeeToken(t *testing.T) { + ctx := context.Background() + rpcClient := client.New(rpcURL) + cid := getChainID(t, rpcClient) + gasPrice := getGasPrice(t, rpcClient) - require.NoError(t, err) - assert.NotNil(t, tx) + sender := createAndFundSigner(t, rpcClient) - _, err = transaction.NewBuilder(big.NewInt(cid)). - SetFeeToken(common.HexToAddress(feeTokenAddress)). - AddCall(recipient, big.NewInt(0), []byte{}). - BuildAndValidate() + feeTokens := []struct { + name string + token common.Address + }{ + {"BetaUSD", betaUSD}, + {"ThetaUSD", thetaUSD}, + } - require.Error(t, err) - assert.Contains(t, err.Error(), "gas must be greater than 0") + for _, ft := range feeTokens { + t.Run(ft.name, func(t *testing.T) { + nonce, err := rpcClient.GetTransactionCount(ctx, sender.Address().Hex()) + require.NoError(t, err) + + tx := transaction.NewBuilder(big.NewInt(cid)). + SetNonce(nonce). + SetGas(300000). + SetMaxFeePerGas(gasPrice). + SetMaxPriorityFeePerGas(gasPrice). + SetFeeToken(ft.token). + AddCall(counterContract, big.NewInt(0), incrementSelector). + Build() + + err = transaction.SignTransaction(tx, sender) + require.NoError(t, err) + + serialized, err := transaction.Serialize(tx, nil) + require.NoError(t, err) + + txHash, err := rpcClient.SendRawTransaction(ctx, serialized) + require.NoError(t, err) + t.Logf("Sent with %s fee token, tx hash: %s", ft.name, txHash) + + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "Transaction failed") + formatReceipt(t, receipt) + }) + } +} + +// TestIntegration_2DNonces tests 2D nonce system (nonce_key) +func TestIntegration_2DNonces(t *testing.T) { + ctx := context.Background() + rpcClient := client.New(rpcURL) + cid := getChainID(t, rpcClient) + gasPrice := getGasPrice(t, rpcClient) + + sender := createAndFundSigner(t, rpcClient) + + // Use different nonce keys for parallel transaction lanes + nonceKeys := []int64{1, 2, 3} + + for _, key := range nonceKeys { + t.Run(fmt.Sprintf("NonceKey_%d", key), func(t *testing.T) { + // Each nonce key starts at 0 + tx := transaction.NewBuilder(big.NewInt(cid)). + SetNonce(0). + SetNonceKey(big.NewInt(key)). + SetGas(300000). + SetMaxFeePerGas(gasPrice). + SetMaxPriorityFeePerGas(gasPrice). + AddCall(counterContract, big.NewInt(0), incrementSelector). + Build() + + err := transaction.SignTransaction(tx, sender) + require.NoError(t, err) + + serialized, err := transaction.Serialize(tx, nil) + require.NoError(t, err) + + txHash, err := rpcClient.SendRawTransaction(ctx, serialized) + require.NoError(t, err) + t.Logf("2D nonce (key=%d) tx hash: %s", key, txHash) + + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "Transaction failed") + formatReceipt(t, receipt) + }) + } } -// TestIntegration_TransactionClone tests the Clone method. -func TestIntegration_TransactionClone(t *testing.T) { +// TestIntegration_ExpiringNonces tests expiring nonces (valid_before, valid_after) +func TestIntegration_ExpiringNonces(t *testing.T) { + ctx := context.Background() rpcClient := client.New(rpcURL) cid := getChainID(t, rpcClient) + gasPrice := getGasPrice(t, rpcClient) - recipient1 := common.HexToAddress("0x1111111111111111111111111111111111111111") - recipient2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + sender := createAndFundSigner(t, rpcClient) - template := transaction.NewBuilder(big.NewInt(cid)). - SetGas(100000). - SetMaxFeePerGas(big.NewInt(10000000000)). - SetFeeToken(common.HexToAddress(feeTokenAddress)). - AddCall(recipient1, big.NewInt(1000), []byte{0xaa}). - Build() + t.Run("ValidBefore", func(t *testing.T) { + // Transaction valid for next 25 seconds + validBefore := uint64(time.Now().Unix() + 25) - cloned := template.Clone() - require.NotNil(t, cloned) + tx := transaction.NewBuilder(big.NewInt(cid)). + SetNonce(0). + SetNonceKey(big.NewInt(100)). // Use unique nonce key + SetValidBefore(validBefore). + SetGas(300000). + SetMaxFeePerGas(gasPrice). + SetMaxPriorityFeePerGas(gasPrice). + AddCall(counterContract, big.NewInt(0), incrementSelector). + Build() - cloned.Calls[0].To = &recipient2 - cloned.Calls[0].Value = big.NewInt(2000) - cloned.Calls[0].Data = []byte{0xbb} + err := transaction.SignTransaction(tx, sender) + require.NoError(t, err) - assert.Equal(t, recipient1, *template.Calls[0].To) - assert.Equal(t, int64(1000), template.Calls[0].Value.Int64()) - assert.Equal(t, []byte{0xaa}, template.Calls[0].Data) + serialized, err := transaction.Serialize(tx, nil) + require.NoError(t, err) - assert.Equal(t, recipient2, *cloned.Calls[0].To) - assert.Equal(t, int64(2000), cloned.Calls[0].Value.Int64()) - assert.Equal(t, []byte{0xbb}, cloned.Calls[0].Data) -} + txHash, err := rpcClient.SendRawTransaction(ctx, serialized) + require.NoError(t, err) + t.Logf("Expiring nonce (validBefore=%d) tx hash: %s", validBefore, txHash) -// TestIntegration_FeePayerTransaction tests the fee payer pattern. -func TestIntegration_FeePayerTransaction(t *testing.T) { - t.Skip("Skipping fee payer transaction test -- this does not work on the dev node yet") - ctx := context.Background() + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "Transaction failed") + formatReceipt(t, receipt) + }) - sender, err := signer.NewSigner(testPrivateKey1) - require.NoError(t, err) + t.Run("ValidAfterAndBefore", func(t *testing.T) { + // Transaction valid after now, before now+25s + now := time.Now().Unix() + validAfter := uint64(now - 1) + validBefore := uint64(now + 25) - feePayer, err := signer.NewSigner(feePayerPrivateKey) - require.NoError(t, err) + tx := transaction.NewBuilder(big.NewInt(cid)). + SetNonce(0). + SetNonceKey(big.NewInt(101)). // Use unique nonce key + SetValidAfter(validAfter). + SetValidBefore(validBefore). + SetGas(300000). + SetMaxFeePerGas(gasPrice). + SetMaxPriorityFeePerGas(gasPrice). + AddCall(counterContract, big.NewInt(0), incrementSelector). + Build() - recipient, err := signer.NewSigner(testPrivateKey2) - require.NoError(t, err) + err := transaction.SignTransaction(tx, sender) + require.NoError(t, err) - t.Logf("Sender address: %s", sender.Address().Hex()) - t.Logf("Fee payer address: %s", feePayer.Address().Hex()) - t.Logf("Recipient address: %s", recipient.Address().Hex()) + serialized, err := transaction.Serialize(tx, nil) + require.NoError(t, err) + txHash, err := rpcClient.SendRawTransaction(ctx, serialized) + require.NoError(t, err) + t.Logf("Expiring nonce (validAfter=%d, validBefore=%d) tx hash: %s", validAfter, validBefore, txHash) + + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "Transaction failed") + formatReceipt(t, receipt) + }) +} + +// TestIntegration_SponsoredTransaction tests sponsored (gasless) transactions +func TestIntegration_SponsoredTransaction(t *testing.T) { + ctx := context.Background() rpcClient := client.New(rpcURL) cid := getChainID(t, rpcClient) + gasPrice := getGasPrice(t, rpcClient) - nonce, err := rpcClient.GetTransactionCount(ctx, sender.Address().Hex()) - require.NoError(t, err) - t.Logf("Sender nonce: %d", nonce) + sender := createAndFundSigner(t, rpcClient) + sponsor := createAndFundSigner(t, rpcClient) + t.Logf("Sender address: %s", sender.Address().Hex()) + t.Logf("Sponsor address: %s", sponsor.Address().Hex()) + + // Create transaction with awaiting_fee_payer flag tx := transaction.NewBuilder(big.NewInt(cid)). - SetNonce(nonce). - SetGas(100000). - SetMaxFeePerGas(big.NewInt(10000000000)). - SetMaxPriorityFeePerGas(big.NewInt(10000000000)). - SetFeeToken(common.HexToAddress(feeTokenAddress)). - AddCall(recipient.Address(), big.NewInt(0), []byte{}). + SetNonce(0). + SetNonceKey(big.NewInt(200)). // Use unique nonce key + SetGas(300000). + SetMaxFeePerGas(gasPrice). + SetMaxPriorityFeePerGas(gasPrice). + AddCall(counterContract, big.NewInt(0), incrementSelector). Build() - t.Logf("Fee token: %s", feeTokenAddress) - - err = transaction.SignTransaction(tx, sender) - require.NoError(t, err) - assert.NotNil(t, tx.Signature) + tx.AwaitingFeePayer = true - senderAddr, err := transaction.VerifySignature(tx) + // Sign as sender + err := transaction.SignTransaction(tx, sender) require.NoError(t, err) - assert.Equal(t, sender.Address(), senderAddr) - err = transaction.AddFeePayerSignature(tx, feePayer) + // Add fee payer signature + err = transaction.AddFeePayerSignature(tx, sponsor) require.NoError(t, err) - assert.NotNil(t, tx.FeePayerSignature) - recoveredSender, recoveredFeePayer, err := transaction.VerifyDualSignatures(tx) + // Verify dual signatures + recoveredSender, recoveredSponsor, err := transaction.VerifyDualSignatures(tx) require.NoError(t, err) assert.Equal(t, sender.Address(), recoveredSender) - assert.Equal(t, feePayer.Address(), recoveredFeePayer) + assert.Equal(t, sponsor.Address(), recoveredSponsor) serialized, err := transaction.Serialize(tx, nil) require.NoError(t, err) - t.Logf("Dual-signed transaction: %s", serialized) - t.Logf("Has fee payer sig: %v", tx.FeePayerSignature != nil) txHash, err := rpcClient.SendRawTransaction(ctx, serialized) require.NoError(t, err) - t.Logf("Transaction hash: %s", txHash) + t.Logf("Sponsored transaction hash: %s", txHash) + + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "Sponsored transaction failed") + formatReceipt(t, receipt) + + // Verify the fee payer in receipt matches sponsor + feePayer, _ := receipt["feePayer"].(string) + assert.True(t, strings.EqualFold(feePayer, sponsor.Address().Hex()), + "Expected feePayer %s, got %s", sponsor.Address().Hex(), feePayer) } -// TestIntegration_BatchTransactions tests sending multiple transactions. -func TestIntegration_BatchTransactions(t *testing.T) { +// TestIntegration_AccessKeys tests access key (keychain) signing +func TestIntegration_AccessKeys(t *testing.T) { + ctx := context.Background() + rpcClient := client.New(rpcURL) + cid := getChainID(t, rpcClient) - sender, err := signer.NewSigner(testPrivateKey1) + // Create root account and access key + rootAccount := createAndFundSigner(t, rpcClient) + accessKeyPriv, err := crypto.GenerateKey() require.NoError(t, err) + accessKey := signer.NewSignerFromKey(accessKeyPriv) - recipients := []common.Address{ - common.HexToAddress("0x1111111111111111111111111111111111111111"), - common.HexToAddress("0x2222222222222222222222222222222222222222"), - common.HexToAddress("0x3333333333333333333333333333333333333333"), - } + t.Logf("Root account: %s", rootAccount.Address().Hex()) + t.Logf("Access key: %s", accessKey.Address().Hex()) + + // Get gas price for transactions + gasPrice := getGasPrice(t, rpcClient) + t.Logf("Using gas price: %s", gasPrice.String()) + + // First, authorize the access key on-chain using EIP-1559 tx (not Tempo tx) + t.Run("AuthorizeAccessKey", func(t *testing.T) { + nonce, err := rpcClient.GetTransactionCount(ctx, rootAccount.Address().Hex()) + require.NoError(t, err) + + // authorizeKey(address key, uint8 sigType, uint64 expiry, bool enforceLimits, (address,uint256)[] limits) + // sigType: 0 = Secp256k1, expiry: 1893456000 (year 2030), enforceLimits: false + calldata := encodeCalldata( + authorizeKeySelector, + addressToBytes32(accessKey.Address()), + padLeft32([]byte{0}), // sigType = 0 (Secp256k1) + uint256ToBytes32(big.NewInt(1893456000)), // expiry + padLeft32([]byte{0}), // enforceLimits = false + uint256ToBytes32(big.NewInt(0xa0)), // offset to limits array + uint256ToBytes32(big.NewInt(0)), // limits array length = 0 + ) + + // Use standard EIP-1559 transaction for authorization + // Gas estimate is ~532000, use 600000 for safety + eip1559Tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: big.NewInt(cid), + Nonce: nonce, + GasTipCap: gasPrice, + GasFeeCap: gasPrice, + Gas: 600000, + To: &accountKeychain, + Value: big.NewInt(0), + Data: calldata, + }) + + signedTx, err := types.SignTx(eip1559Tx, types.NewLondonSigner(big.NewInt(cid)), rootAccount.PrivateKey()) + require.NoError(t, err) + + txBytes, err := signedTx.MarshalBinary() + require.NoError(t, err) + + txHash, err := rpcClient.SendRawTransaction(ctx, "0x"+hex.EncodeToString(txBytes)) + require.NoError(t, err) + t.Logf("Authorize access key tx hash: %s", txHash) + + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get authorization receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "Authorization tx failed") + t.Logf("Authorization tx succeeded") + formatReceipt(t, receipt) + }) + + // Access key doesn't need funding - it signs on behalf of root account + // But we wait a bit more to ensure authorization is fully propagated + time.Sleep(3 * time.Second) + + // Now use the access key to sign a transaction + t.Run("SignWithAccessKey", func(t *testing.T) { + tx := transaction.NewBuilder(big.NewInt(cid)). + SetNonce(0). + SetNonceKey(big.NewInt(300)). // Use unique nonce key + SetGas(500000). + SetMaxFeePerGas(gasPrice). + SetMaxPriorityFeePerGas(gasPrice). + AddCall(counterContract, big.NewInt(0), incrementSelector). + Build() + + err := keychain.SignWithAccessKey(tx, accessKey, rootAccount.Address()) + require.NoError(t, err) + + // Verify the access key signature + recoveredAccessKey, recoveredRoot, err := keychain.VerifyAccessKeySignature(tx) + require.NoError(t, err) + assert.Equal(t, accessKey.Address(), recoveredAccessKey) + assert.Equal(t, rootAccount.Address(), recoveredRoot) + serialized, err := transaction.Serialize(tx, nil) + require.NoError(t, err) + + txHash, err := rpcClient.SendRawTransaction(ctx, serialized) + require.NoError(t, err) + t.Logf("Access key signed tx hash: %s", txHash) + + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "Access key transaction failed") + formatReceipt(t, receipt) + }) +} + +// TestIntegration_BatchTransactions tests transactions with multiple calls +func TestIntegration_BatchTransactions(t *testing.T) { + ctx := context.Background() rpcClient := client.New(rpcURL) cid := getChainID(t, rpcClient) + gasPrice := getGasPrice(t, rpcClient) - baseNonce, err := rpcClient.GetTransactionCount(ctx, sender.Address().Hex()) - require.NoError(t, err) + sender := createAndFundSigner(t, rpcClient) + + t.Run("TwoCalls", func(t *testing.T) { + nonce, err := rpcClient.GetTransactionCount(ctx, sender.Address().Hex()) + require.NoError(t, err) - var txHashes []string - for i, recipient := range recipients { tx := transaction.NewBuilder(big.NewInt(cid)). - SetGas(100000). - SetMaxFeePerGas(big.NewInt(10000000000)). - SetMaxPriorityFeePerGas(big.NewInt(10000000000)). - SetFeeToken(common.HexToAddress(feeTokenAddress)). - SetNonce(baseNonce+uint64(i)). - AddCall(recipient, big.NewInt(0), []byte{}). + SetNonce(nonce). + SetGas(300000). + SetMaxFeePerGas(gasPrice). + SetMaxPriorityFeePerGas(gasPrice). + AddCall(counterContract, big.NewInt(0), incrementSelector). + AddCall(counterContract, big.NewInt(0), incrementSelector). Build() err = transaction.SignTransaction(tx, sender) @@ -289,79 +733,170 @@ func TestIntegration_BatchTransactions(t *testing.T) { txHash, err := rpcClient.SendRawTransaction(ctx, serialized) require.NoError(t, err) + t.Logf("Batch (2 calls) tx hash: %s", txHash) - txHashes = append(txHashes, txHash) - t.Logf("Transaction %d hash: %s", i+1, txHash) - } + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "Batch transaction failed") + formatReceipt(t, receipt) + }) + + t.Run("ThreeCalls", func(t *testing.T) { + nonce, err := rpcClient.GetTransactionCount(ctx, sender.Address().Hex()) + require.NoError(t, err) - assert.Equal(t, len(recipients), len(txHashes)) + tx := transaction.NewBuilder(big.NewInt(cid)). + SetNonce(nonce). + SetGas(300000). + SetMaxFeePerGas(gasPrice). + SetMaxPriorityFeePerGas(gasPrice). + AddCall(counterContract, big.NewInt(0), incrementSelector). + AddCall(counterContract, big.NewInt(0), incrementSelector). + AddCall(counterContract, big.NewInt(0), incrementSelector). + Build() + + err = transaction.SignTransaction(tx, sender) + require.NoError(t, err) + + serialized, err := transaction.Serialize(tx, nil) + require.NoError(t, err) + + txHash, err := rpcClient.SendRawTransaction(ctx, serialized) + require.NoError(t, err) + t.Logf("Batch (3 calls) tx hash: %s", txHash) + + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "Batch transaction failed") + formatReceipt(t, receipt) + }) } -// TestIntegration_MultiCall tests transactions with multiple calls. -func TestIntegration_MultiCall(t *testing.T) { +// TestIntegration_SetUserFeeToken tests setting user's default fee token +func TestIntegration_SetUserFeeToken(t *testing.T) { ctx := context.Background() + rpcClient := client.New(rpcURL) + cid := getChainID(t, rpcClient) + gasPrice := getGasPrice(t, rpcClient) - sender, err := signer.NewSigner(testPrivateKey1) - require.NoError(t, err) + sender := createAndFundSigner(t, rpcClient) + + t.Run("SetToBetaUSD", func(t *testing.T) { + nonce, err := rpcClient.GetTransactionCount(ctx, sender.Address().Hex()) + require.NoError(t, err) + calldata := encodeCalldata(setUserTokenSelector, addressToBytes32(betaUSD)) + + tx := transaction.NewBuilder(big.NewInt(cid)). + SetNonce(nonce). + SetGas(600000). + SetMaxFeePerGas(gasPrice). + SetMaxPriorityFeePerGas(gasPrice). + AddCall(feeController, big.NewInt(0), calldata). + Build() + + err = transaction.SignTransaction(tx, sender) + require.NoError(t, err) + + serialized, err := transaction.Serialize(tx, nil) + require.NoError(t, err) + + txHash, err := rpcClient.SendRawTransaction(ctx, serialized) + require.NoError(t, err) + t.Logf("Set user fee token to BetaUSD tx hash: %s", txHash) + + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "SetUserFeeToken failed") + formatReceipt(t, receipt) + }) + + t.Run("ResetToNative", func(t *testing.T) { + nonce, err := rpcClient.GetTransactionCount(ctx, sender.Address().Hex()) + require.NoError(t, err) + + calldata := encodeCalldata(setUserTokenSelector, addressToBytes32(nativeFeeToken)) + + tx := transaction.NewBuilder(big.NewInt(cid)). + SetNonce(nonce). + SetGas(600000). + SetMaxFeePerGas(gasPrice). + SetMaxPriorityFeePerGas(gasPrice). + AddCall(feeController, big.NewInt(0), calldata). + Build() + + err = transaction.SignTransaction(tx, sender) + require.NoError(t, err) + + serialized, err := transaction.Serialize(tx, nil) + require.NoError(t, err) + + txHash, err := rpcClient.SendRawTransaction(ctx, serialized) + require.NoError(t, err) + t.Logf("Reset user fee token to native tx hash: %s", txHash) + + receipt := waitForReceipt(t, rpcClient, txHash) + require.NotNil(t, receipt, "Failed to get receipt") + status, _ := receipt["status"].(string) + require.Equal(t, "0x1", status, "ResetUserFeeToken failed") + formatReceipt(t, receipt) + }) +} + +// TestIntegration_DEXOperations tests DEX operations (skipped - require liquidity setup) +func TestIntegration_DEXOperations(t *testing.T) { + t.Skip("DEX operations require liquidity setup - works with full tempo-check.sh flow") + + // TODO: Implement DEX tests when liquidity setup is available: + // - Approve DEX + // - Place bid + // - Place ask + // - Place flip + // - Swap exact amount in + // - Swap exact amount out +} + +// TestIntegration_BuilderValidation tests the BuildAndValidate method +func TestIntegration_BuilderValidation(t *testing.T) { rpcClient := client.New(rpcURL) cid := getChainID(t, rpcClient) - nonce, err := rpcClient.GetTransactionCount(ctx, sender.Address().Hex()) - require.NoError(t, err) - - tx := transaction.NewBuilder(big.NewInt(cid)). - SetNonce(nonce). - SetGas(200000). - SetMaxFeePerGas(big.NewInt(10000000000)). - SetMaxPriorityFeePerGas(big.NewInt(10000000000)). - SetFeeToken(common.HexToAddress(feeTokenAddress)). - AddCall( - common.HexToAddress("0x1111111111111111111111111111111111111111"), - big.NewInt(0), - []byte{}, - ). - AddCall( - common.HexToAddress("0x2222222222222222222222222222222222222222"), - big.NewInt(0), - []byte{}, - ). - AddCall( - common.HexToAddress("0x3333333333333333333333333333333333333333"), - big.NewInt(0), - []byte{}, - ). - Build() + recipient := common.HexToAddress("0x1234567890123456789012345678901234567890") - assert.Equal(t, 3, len(tx.Calls), "Should have 3 calls") + tx, err := transaction.NewBuilder(big.NewInt(cid)). + SetGas(300000). + AddCall(recipient, big.NewInt(0), []byte{}). + BuildAndValidate() - err = transaction.SignTransaction(tx, sender) require.NoError(t, err) + assert.NotNil(t, tx) - serialized, err := transaction.Serialize(tx, nil) - require.NoError(t, err) + _, err = transaction.NewBuilder(big.NewInt(cid)). + AddCall(recipient, big.NewInt(0), []byte{}). + BuildAndValidate() - txHash, err := rpcClient.SendRawTransaction(ctx, serialized) - require.NoError(t, err) - t.Logf("Multi-call transaction hash: %s", txHash) + require.Error(t, err) + assert.Contains(t, err.Error(), "gas must be greater than 0") } -// TestIntegration_RoundTrip tests full serialization round-trip. +// TestIntegration_RoundTrip tests full serialization round-trip func TestIntegration_RoundTrip(t *testing.T) { rpcClient := client.New(rpcURL) cid := getChainID(t, rpcClient) - sender, err := signer.NewSigner(testPrivateKey1) + senderPriv, err := crypto.GenerateKey() require.NoError(t, err) + sender := signer.NewSignerFromKey(senderPriv) recipient := common.HexToAddress("0x1234567890123456789012345678901234567890") - // Create and sign transaction originalTx := transaction.NewBuilder(big.NewInt(cid)). - SetGas(100000). + SetGas(300000). SetMaxFeePerGas(big.NewInt(10000000000)). SetMaxPriorityFeePerGas(big.NewInt(10000000000)). - SetFeeToken(common.HexToAddress(feeTokenAddress)). SetNonce(42). AddCall(recipient, big.NewInt(1000), []byte{0xaa, 0xbb}). Build() @@ -387,19 +922,3 @@ func TestIntegration_RoundTrip(t *testing.T) { require.NoError(t, err) assert.Equal(t, sender.Address(), recoveredAddr) } - -// TestIntegration_Options tests client configuration options. -func TestIntegration_Options(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - rpcClient := client.New( - rpcURL, - client.WithTimeout(10*time.Second), - ) - - blockNum, err := rpcClient.GetBlockNumber(ctx) - require.NoError(t, err) - assert.Greater(t, blockNum, uint64(0)) - t.Logf("Block number: %d", blockNum) -}