Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(zetaclient): SUI withdrawals #3562

Merged
merged 26 commits into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1f4674c
Simplify gw
swift1337 Feb 19, 2025
26a4e66
Add client.GetOwnedObjectID()
swift1337 Feb 19, 2025
f856f86
Add gatewayObjectID
swift1337 Feb 19, 2025
cd62713
Update cursor parsing to be consistent w/ gw address chain params
swift1337 Feb 19, 2025
6833773
Add defaultInterval for scheduler
swift1337 Feb 19, 2025
9777494
Implement Sui-related crypto. Improve gateway
swift1337 Feb 20, 2025
b23af23
Add sui.SerializeSignatureECDSA
swift1337 Feb 20, 2025
5d31e75
update gw params + scheduler boilerplate
swift1337 Feb 20, 2025
ce8a8b5
Withdrawals WIP
swift1337 Feb 20, 2025
dda8521
Deduplicate Sui ECDSA signer
swift1337 Feb 21, 2025
688be0d
Add Withdraw event
swift1337 Feb 21, 2025
8044d7d
Add PostOutboundTracker
swift1337 Feb 24, 2025
853dc46
Add ProcessCCTX test cases
swift1337 Feb 24, 2025
b0738b0
Merge branch 'develop' into feat/sui/withdrawals
swift1337 Feb 24, 2025
eff81f8
Add DeserializeSignatureECDSA
swift1337 Feb 24, 2025
8f3455b
Minor TSS getters improvement
swift1337 Feb 25, 2025
88c6370
Minor crypto improvement
swift1337 Feb 25, 2025
d3a2417
MarkOutbound
swift1337 Feb 25, 2025
20bead3
Add ProcessOutboundTrackers w/ unit test
swift1337 Feb 25, 2025
d317e11
Implement VoteOutbound with unit test
swift1337 Feb 25, 2025
ef9c8ff
Implement sui.scheduleCCTX(...)
swift1337 Feb 25, 2025
0f34c7f
Merge branch 'develop' into feat/sui/withdrawals
swift1337 Feb 25, 2025
490890a
Address PR comments
swift1337 Feb 26, 2025
9fa5d1a
Address PR comments [2]
swift1337 Feb 26, 2025
22c26f2
Merge branch 'develop' into feat/sui/withdrawals
swift1337 Feb 26, 2025
506896a
update changelog
swift1337 Feb 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/zetaclientd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ func Start(_ *cobra.Command, _ []string) error {
// Orchestrator wraps the zetacore client and adds the observers and signer maps to it.
// This is the high level object used for CCTX interactions
// It also handles background configuration updates from zetacore
taskScheduler := scheduler.New(logger.Std)
taskScheduler := scheduler.New(logger.Std, 0)
maestroDeps := &orchestrator.Dependencies{
Zetacore: zetacoreClient,
TSS: tss,
Expand Down
81 changes: 81 additions & 0 deletions pkg/contracts/sui/crypto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package sui

import (
"crypto/ecdsa"
"crypto/elliptic"
"encoding/base64"
"encoding/hex"

"github.com/block-vision/sui-go-sdk/models"
"github.com/pkg/errors"
"golang.org/x/crypto/blake2b"
)

const flagSecp256k1 = 0x01

// Digest calculates tx digest (hash) for further signing by TSS.
func Digest(tx models.TxnMetaData) ([32]byte, error) {
txBytes, err := base64.StdEncoding.DecodeString(tx.TxBytes)
if err != nil {
return [32]byte{}, errors.Wrap(err, "failed to decode tx bytes")
}

message := messageWithIntentPrefix(txBytes)

// "When invoking the signing API, you must first hash the intent message of the tx
// data to 32 bytes using Blake2b ... For ECDSA Secp256k1 and Secp256r1,
// you must use SHA256 as the internal hash function"
// https://docs.sui.io/concepts/cryptography/transaction-auth/signatures#signature-requirements
return blake2b.Sum256(message), nil
}

// https://github.com/MystenLabs/sui/blob/0dc1a38f800fc2d8fabe11477fdef702058cf00d/crates/sui-types/src/intent.rs
// #1 = IntentScope(transactionData=0)
// #2 = Version(0)
// #3 = AppId(Sui=0)
var defaultIntent = []byte{0, 0, 0}

// Constructs binary message with intent prefix.
// https://docs.sui.io/concepts/cryptography/transaction-auth/intent-signing#structs
func messageWithIntentPrefix(message []byte) []byte {
glued := make([]byte, len(defaultIntent)+len(message))
copy(glued, defaultIntent)
copy(glued[len(defaultIntent):], message)

return glued
}

// AddressFromPubKeyECDSA converts ECDSA public key to Sui address.
// https://docs.sui.io/concepts/cryptography/transaction-auth/keys-addresses
// https://docs.sui.io/concepts/cryptography/transaction-auth/signatures
func AddressFromPubKeyECDSA(pk *ecdsa.PublicKey) string {
pubBytes := elliptic.MarshalCompressed(pk.Curve, pk.X, pk.Y)

raw := make([]byte, 1+len(pubBytes))
raw[0] = flagSecp256k1
copy(raw[1:], pubBytes)

addrBytes := blake2b.Sum256(raw)

return "0x" + hex.EncodeToString(addrBytes[:])
}

// SerializeSignatureECDSA serializes secp256k1 sig (R|S|V) and a publicKey into base64 string
// https://docs.sui.io/concepts/cryptography/transaction-auth/signatures
func SerializeSignatureECDSA(signature [65]byte, publicKey []byte) (string, error) {
// we don't need the last V byte for recovery
const sigLen = 64

// compressed public key
const pubKeyLen = 33
if len(publicKey) != pubKeyLen {
return "", errors.Errorf("invalid publicKey length (got %d, want %d)", len(publicKey), pubKeyLen)
}

serialized := make([]byte, 1+sigLen+pubKeyLen)
serialized[0] = flagSecp256k1
copy(serialized[1:], signature[:sigLen])
copy(serialized[1+sigLen:], publicKey)

return base64.StdEncoding.EncodeToString(serialized), nil
}
95 changes: 95 additions & 0 deletions pkg/contracts/sui/crypto_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package sui

import (
"encoding/base64"
"testing"

"github.com/block-vision/sui-go-sdk/models"
"github.com/ethereum/go-ethereum/crypto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCrypto(t *testing.T) {
t.Run("Digest", func(t *testing.T) {
// ARRANGE
// Given a tx (imagine client.MoveCall(...) result)
//
// Data was generated using sui cli:
// sui client transfer-sui --to "0xac5bceec1b789ff840d7d4e6ce4ce61c90d190a7f8c4f4ddf0bff6ee2413c33c" \
// --sui-coin-object-id "0x0466a9a57add505b7b85ac485054f9b71f574f4504d9c70acd8f73ef11e0dc30" \
// --gas-budget 500000 --serialize-unsigned-transaction
//
// https://docs.sui.io/concepts/cryptography/transaction-auth/offline-signing#sign
tx := models.TxnMetaData{
TxBytes: "AAABACCsW87sG3if+EDX1ObOTOYckNGQp/jE9N3wv/buJBPDPAEBAQABAACsW87sG3if+EDX1ObOTOYckNGQp/jE9N3wv/buJBPDPAEEZqmlet1QW3uFrEhQVPm3H1dPRQTZxwrNj3PvEeDcMPCkyhwAAAAAICNNoyg5v4obnoVYDWw0XhxB6Tq/b+OPXnJKPc2+QM5QrFvO7Bt4n/hA19TmzkzmHJDRkKf4xPTd8L/27iQTwzzuAgAAAAAAACChBwAAAAAAAA==",
}

// Given expected digest based on SUI cli:
// https://docs.sui.io/concepts/cryptography/transaction-auth/offline-signing#sign
// sui keytool sign --address "..." --data "$txBytesBase64" --json | jq ".digest"
const expectedDigestBase64 = "A1NY74R1IScWR/GPtOMNHVY/RwTNzWHlUbOkwp3911g="

// ACT
digest, err := Digest(tx)

digestBase64 := base64.StdEncoding.EncodeToString(digest[:])

// ASSERT
require.NoError(t, err)
require.Equal(t, expectedDigestBase64, digestBase64)
})

t.Run("AddressFromPubKeyECDSA", func(t *testing.T) {
// `$> sui keytool generate secp256k1`
for _, tt := range []struct {
pubKey string
address string
}{
{
pubKey: "AQJz6a5yi6Wtf55atMWlW/ZA4Xdd6lJKC22u3Xi/h9yeBw==",
address: "0xccf49bfb6c8159f5e53c80f5b6ecf748e4af89c8c10c27d24302207b2bc97744",
},
{
pubKey: "AQKUgO1kyhheTjbzYYhP67nxDD1UZwEhqkLyX1KRBm1xTQ==",
address: "0x2dc141f8a8d8a3fe397054f538dcc8207fd5edf4a1415c54b7d5a4dc124d9b3e",
},
{
pubKey: "AQIgwiNQwm529+fvaKW/n5ITbaQVUToZq+ZIpNjjOw7Spw==",
address: "0x17012be22c34ad1396f8af272b2e7b0edb529b3441912bd532faf874bf2c9262",
},
} {
// ARRANGE
pubKeyBytes, err := base64.StdEncoding.DecodeString(tt.pubKey)
require.NoError(t, err)

// type_flag + compression_flag + 32bytes
require.Equal(t, 1+1+32, len(pubKeyBytes))

pk, err := crypto.DecompressPubkey(pubKeyBytes[1:])
require.NoError(t, err)

// ACT
addr := AddressFromPubKeyECDSA(pk)

// ASSERT
assert.Equal(t, tt.address, addr)
}
})

t.Run("SerializeSignatureECDSA", func(t *testing.T) {
// ARRANGE
signature := [65]byte{0, 1, 3}
pubKey := [33]byte{4, 5, 6}

// ACT
res, err := SerializeSignatureECDSA(signature, pubKey[:])

// ASSERT
require.NoError(t, err)

resBin, err := base64.StdEncoding.DecodeString(res)
require.NoError(t, err)
require.Equal(t, (1 + 64 + 33), len(resBin))
})
}
85 changes: 68 additions & 17 deletions pkg/contracts/sui/gateway.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package sui

import (
"fmt"
"strconv"
"strings"
"sync"

"github.com/block-vision/sui-go-sdk/models"
"github.com/pkg/errors"
Expand All @@ -16,7 +18,13 @@ type EventType string

// Gateway contains the API to read inbounds and sign outbounds to the Sui gateway
type Gateway struct {
// packageID is the package ID of the gateway
packageID string

// gatewayObjectID is the object ID of the gateway struct
objectID string

mu sync.RWMutex
}

// SUI is the coin type for SUI, native gas token
Expand All @@ -33,11 +41,20 @@ const moduleName = "gateway"
// ErrParseEvent event parse error
var ErrParseEvent = errors.New("event parse error")

// NewGateway creates a new Sui gateway
// Note: packageID is the equivalent for gateway address or program ID on Solana
// It's what will be set in gateway chain params
func NewGateway(packageID string) *Gateway {
return &Gateway{packageID: packageID}
// NewGatewayFromPairID creates a new Sui Gateway
// from pair of `$packageID,$gatewayObjectID`
func NewGatewayFromPairID(pair string) (*Gateway, error) {
packageID, gatewayObjectID, err := parsePair(pair)
if err != nil {
return nil, err
}

return NewGateway(packageID, gatewayObjectID), nil
}

// NewGateway creates a new Sui Gateway.
func NewGateway(packageID string, gatewayObjectID string) *Gateway {
return &Gateway{packageID: packageID, objectID: gatewayObjectID}
}

// Event represents generic event wrapper
Expand All @@ -47,29 +64,58 @@ type Event struct {
EventType EventType

content any
inbound bool
}

// IsInbound checks whether event is Inbound.
func (e *Event) IsInbound() bool { return e.inbound }

// Inbound extract Inbound.
func (e *Event) Inbound() (Inbound, error) {
if !e.inbound {
return Inbound{}, errors.Errorf("not an inbound (%+v)", e.content)
v, ok := e.content.(Inbound)
if !ok {
return Inbound{}, errors.Errorf("invalid content type %T", e.content)
}

return e.content.(Inbound), nil
return v, nil
}

// PackageID returns object id of Gateway code
func (gw *Gateway) PackageID() string {
gw.mu.RLock()
defer gw.mu.RUnlock()
return gw.packageID
}

// ObjectID returns Gateway's struct object id
func (gw *Gateway) ObjectID() string {
gw.mu.RLock()
defer gw.mu.RUnlock()
return gw.objectID
}

// Module returns Gateway's module name
func (gw *Gateway) Module() string {
return moduleName
}

// WithdrawCapType returns struct type of the WithdrawCap
func (gw *Gateway) WithdrawCapType() string {
return fmt.Sprintf("%s::%s::WithdrawCap", gw.PackageID(), moduleName)
}

// UpdateIDs updates packageID and objectID.
func (gw *Gateway) UpdateIDs(pair string) error {
packageID, gatewayObjectID, err := parsePair(pair)
if err != nil {
return err
}

gw.mu.Lock()
defer gw.mu.Unlock()

gw.packageID = packageID
gw.objectID = gatewayObjectID

return nil
}

// ParseEvent parses Event.
func (gw *Gateway) ParseEvent(event models.SuiEventResponse) (Event, error) {
// basic validation
Expand Down Expand Up @@ -107,14 +153,12 @@ func (gw *Gateway) ParseEvent(event models.SuiEventResponse) (Event, error) {

var (
eventType = descriptor.eventType
inbound bool
content any
)

// Parse specific events
switch eventType {
case Deposit, DepositAndCall:
inbound = true
content, err = parseInbound(event, eventType)
default:
return Event{}, errors.Wrapf(ErrParseEvent, "unknown event %q", eventType)
Expand All @@ -128,9 +172,7 @@ func (gw *Gateway) ParseEvent(event models.SuiEventResponse) (Event, error) {
TxHash: txHash,
EventIndex: eventID,
EventType: eventType,

content: content,
inbound: inbound,
content: content,
}, nil
}

Expand Down Expand Up @@ -184,3 +226,12 @@ func convertPayload(data []any) ([]byte, error) {

return payload, nil
}

func parsePair(pair string) (string, string, error) {
parts := strings.Split(pair, ",")
if len(parts) != 2 {
return "", "", errors.Errorf("invalid pair %q", pair)
}

return parts[0], parts[1], nil
}
9 changes: 2 additions & 7 deletions pkg/contracts/sui/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func TestParseEvent(t *testing.T) {
// stubs
const (
packageID = "0x3e9fb7c01ef0d97911ccfec79306d9de2d58daa996bd3469da0f6d640cc443cf"
gatewayID = "0x444fb7c01ef0d97911ccfec79306d9de2d58daa996bd3469da0f6d640cc443aa"
sender = "0x70386a9a912d9f7a603263abfbd8faae861df0ee5f8e2dbdf731fbd159f10e52"
txHash = "HjxLMxMXNz8YfUc2qT4e4CrogKvGeHRbDW7Arr6ntzqq"
)
Expand All @@ -23,7 +24,7 @@ func TestParseEvent(t *testing.T) {
return fmt.Sprintf("%s::%s::%s", packageID, moduleName, t)
}

gw := NewGateway(packageID)
gw := NewGateway(packageID, gatewayID)

receiverAlice := sample.EthAddress()
receiverBob := sample.EthAddress()
Expand Down Expand Up @@ -53,8 +54,6 @@ func TestParseEvent(t *testing.T) {
assert.Equal(t, Deposit, out.EventType)
assert.Equal(t, uint64(0), out.EventIndex)

assert.True(t, out.IsInbound())

inbound, err := out.Inbound()
require.NoError(t, err)

Expand Down Expand Up @@ -85,8 +84,6 @@ func TestParseEvent(t *testing.T) {
assert.Equal(t, DepositAndCall, out.EventType)
assert.Equal(t, uint64(1), out.EventIndex)

assert.True(t, out.IsInbound())

inbound, err := out.Inbound()
require.NoError(t, err)

Expand Down Expand Up @@ -118,8 +115,6 @@ func TestParseEvent(t *testing.T) {
assert.Equal(t, DepositAndCall, out.EventType)
assert.Equal(t, uint64(1), out.EventIndex)

assert.True(t, out.IsInbound())

inbound, err := out.Inbound()
require.NoError(t, err)

Expand Down
Loading
Loading