Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
01effe6
Add key_mode DB column, user model, and SDK prepare/execute transfer
salindne Mar 9, 2026
33e124a
fix: use IssuerParty instead of RelayerParty after config-v2 rename
salindne Mar 13, 2026
e9b0910
Make SDK token client stateless, add request validation
salindne Mar 17, 2026
dd69d73
Add non-custodial transfer HTTP API (prepare/execute endpoints)
salindne Mar 9, 2026
75e867d
Move transfer cache to service layer, use token config for symbols
salindne Mar 17, 2026
6e7251c
Add two-step external user registration with topology signing
salindne Mar 9, 2026
2e469df
Move whitelist check before external registration branch
salindne Mar 17, 2026
23d9cb0
fix: align test mock expectations with whitelist-first check order
salindne Mar 17, 2026
c14f8b0
Add integration test and Snap testing documentation
salindne Mar 9, 2026
87d2607
Fix test expectations for whitelist check reorder, remove dead assign…
salindne Mar 17, 2026
73b9209
Merge pull request #155 from ChainSafe/feature/111-e-integration-test
salindne Mar 17, 2026
44b9fbb
Merge pull request #154 from ChainSafe/feature/111-d-registration
salindne Mar 17, 2026
e7092e3
Merge pull request #153 from ChainSafe/feature/111-c-http-api
salindne Mar 17, 2026
18329c0
fix: cache TTL ownership, add size limit, remove redundant migration
salindne Mar 18, 2026
0c8f3a0
refactor: extract cache and service interfaces, add transfer log deco…
salindne Mar 18, 2026
4e8b20c
test: add unit tests for PrepareExternalRegistration and TransferService
salindne Mar 18, 2026
0977899
refactor: move request validation from service layer to HTTP handlers
salindne Mar 18, 2026
76d4536
fix: use crypto.DecompressPubkey for secp256k1 key decompression
salindne Mar 18, 2026
dbd3e90
refactor: move PreparedTransferCache from cantonsdk to transfer package
salindne Mar 19, 2026
20ee795
refactor: remove Start() from TransferCache interface
salindne Mar 19, 2026
1fd0a61
fix: gofmt alignment in server constants
salindne Mar 19, 2026
b846be4
refactor: replace hand-written mocks with mockery-generated mocks
salindne Mar 19, 2026
c814cee
docs: fix fingerprint format and error message in snap testing guide
salindne Mar 19, 2026
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
529 changes: 529 additions & 0 deletions docs/non-custodial-snap-testing.md

Large diffs are not rendered by default.

36 changes: 34 additions & 2 deletions pkg/app/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/chainsafe/canton-middleware/pkg/registry"
"github.com/chainsafe/canton-middleware/pkg/token"
tokenprovider "github.com/chainsafe/canton-middleware/pkg/token/provider"
"github.com/chainsafe/canton-middleware/pkg/transfer"
userservice "github.com/chainsafe/canton-middleware/pkg/user/service"
"github.com/chainsafe/canton-middleware/pkg/userstore"

Expand All @@ -32,7 +33,12 @@ import (
"go.uber.org/zap"
)

const defaultRequestTimeout = 60
const (
defaultRequestTimeout = 60
topologyCacheTTL = 5 * time.Minute
transferCacheTTL = 2 * time.Minute
transferCacheMaxSize = 10000
)

// Server holds cfg to init the api server.
type Server struct {
Expand Down Expand Up @@ -99,19 +105,28 @@ func (s *Server) Run() error {
// Keep this defer as a safety net.
defer stopReconcile()

topologyCache := userservice.NewTopologyCache(topologyCacheTTL)
go topologyCache.Start(ctx)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The topologyCache.Start(ctx) is launched as a goroutine without explicit panic recovery. While the Start method itself handles context cancellation, a panic originating from within the cleanup method or any other part of this goroutine could lead to an unhandled crash of the application. Consider adding a defer with recover() to log and handle such panics gracefully.


registrationService := userservice.NewService(
userStore,
cantonClient.Identity,
cipher,
logger,
cfg.SkipCantonSigVerify,
topologyCache,
)

tokenDataProvider := tokenprovider.NewCanton(cantonClient.Token)
tokenService := token.NewTokenService(cfg.Token, tokenDataProvider, userStore, cantonClient.Token)
evmStore := ethrpcstore.NewStore(dbBun)

router := s.setupRouter(evmStore, cantonClient, tokenService, userservice.NewLog(registrationService, logger), logger)
transferCache := transfer.NewPreparedTransferCache(transferCacheTTL, transferCacheMaxSize)
go transferCache.Start(ctx)
transferSvc := transfer.NewTransferService(cantonClient.Token, userStore, transferCache, tokenSymbols(cfg.Token))
regSvcLog := userservice.NewLog(registrationService, logger)
transferSvcLog := transfer.NewLog(transferSvc, logger)
router := s.setupRouter(evmStore, cantonClient, tokenService, regSvcLog, transferSvcLog, logger)

err = apphttp.ServeAndWait(ctx, router, logger, cfg.Server)

Expand Down Expand Up @@ -210,6 +225,7 @@ func (s *Server) setupRouter(
cantonClient *canton.Client,
tokenService *token.Service,
registrationService userservice.Service,
transferSvc transfer.Service,
logger *zap.Logger,
) chi.Router {
r := chi.NewRouter()
Expand All @@ -229,6 +245,9 @@ func (s *Server) setupRouter(
// Registration endpoints
userservice.RegisterRoutes(r, registrationService, logger)

// Non-custodial transfer endpoints (prepare/execute)
transfer.RegisterRoutes(r, transferSvc, logger)

registryHandler := registry.NewHandler(cantonClient.Token, logger)
r.Handle("/registry/transfer-instruction/v1/transfer-factory", registryHandler)
logger.Info("Splice Registry API enabled",
Expand All @@ -242,3 +261,16 @@ func (s *Server) setupRouter(

return r
}

// tokenSymbols extracts the unique symbol strings from the token config.
func tokenSymbols(cfg *token.Config) []string {
seen := make(map[string]bool, len(cfg.SupportedTokens))
var symbols []string
for _, tkn := range cfg.SupportedTokens {
if !seen[tkn.Symbol] {
seen[tkn.Symbol] = true
symbols = append(symbols, tkn.Symbol)
}
}
return symbols
}
19 changes: 19 additions & 0 deletions pkg/app/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const (
CategoryDataConflict
// CategoryLocked The client is not able to access the requested resource due to its locked state
CategoryLocked
// CategoryGone The resource existed but is no longer available (e.g. expired)
CategoryGone
// CategoryDependencyFailure A dependent service is throwing errors
CategoryDependencyFailure
// CategoryGeneralError The service failed in an unexpected way
Expand All @@ -54,6 +56,8 @@ func (c Category) String() string {
return "CategoryDataConflict"
case CategoryLocked:
return "CategoryLocked"
case CategoryGone:
return "CategoryGone"
case CategoryDependencyFailure:
return "CategoryDependencyFailure"
case CategoryRecovering:
Expand Down Expand Up @@ -208,6 +212,19 @@ func DependencyError(err error, message string) error {
}
}

// GoneError returns an error with category CategoryGone (HTTP 410).
// Use when a resource existed but is no longer available (e.g. expired transfers).
func GoneError(err error, message string) error {
if err == nil {
err = errors.New("gone: " + message)
}
return &ServiceError{
Category: CategoryGone,
Message: message,
Err: err,
}
}

// ConflictError returns an error with category CategoryDataConflict
// the error message provided is returned to the user
// the error object provided is logged in logger
Expand Down Expand Up @@ -253,6 +270,8 @@ func (err ServiceError) StatusCode() int {
return http.StatusConflict
case CategoryLocked:
return http.StatusLocked
case CategoryGone:
return http.StatusGone
case CategoryDependencyFailure:
return http.StatusBadGateway
case CategoryGeneralError:
Expand Down
29 changes: 29 additions & 0 deletions pkg/auth/evm.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package auth
import (
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
Expand Down Expand Up @@ -69,3 +71,30 @@ func ValidateEVMAddress(address string) bool {
func NormalizeAddress(address string) string {
return common.HexToAddress(address).Hex()
}

// ValidateTimedMessage checks that a message contains a Unix timestamp suffix
// (format: "{prefix}:{unix_seconds}") and that it is within maxAge of now.
// This provides replay protection: captured signatures expire after maxAge.
func ValidateTimedMessage(msg string, maxAge time.Duration) error {
idx := strings.LastIndex(msg, ":")
if idx < 0 || idx == len(msg)-1 {
return fmt.Errorf("message must contain a colon-separated Unix timestamp (e.g. transfer:1710000000)")
}

tsStr := msg[idx+1:]
ts, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp in message: %w", err)
}

msgTime := time.Unix(ts, 0)
age := time.Since(msgTime)
if age < 0 {
age = -age
}
if age > maxAge {
return fmt.Errorf("message expired: timestamp is %s old (max %s)", age.Truncate(time.Second), maxAge)
}

return nil
}
72 changes: 72 additions & 0 deletions pkg/cantonsdk/identity/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ type Identity interface {
GetFingerprintMapping(ctx context.Context, fingerprint string) (*FingerprintMapping, error)

GrantActAsParty(ctx context.Context, partyID string) error

// GenerateExternalPartyTopology generates the topology transactions and multi-hash
// needed for external party allocation. The multi-hash must be signed by the party's
// private key and submitted via AllocateExternalPartyWithSignature.
GenerateExternalPartyTopology(ctx context.Context, hint string, spkiPublicKey []byte) (*ExternalPartyTopology, error)

// AllocateExternalPartyWithSignature completes external party allocation using
// a client-provided DER signature of the topology multi-hash.
AllocateExternalPartyWithSignature(ctx context.Context, topology *ExternalPartyTopology, derSignature []byte) (*Party, error)
}

// Client implements the Identity interface.
Expand Down Expand Up @@ -142,6 +151,69 @@ func (c *Client) AllocateExternalParty(ctx context.Context, hint string, spkiPub
}, nil
}

func (c *Client) GenerateExternalPartyTopology(ctx context.Context, hint string, spkiPublicKey []byte) (*ExternalPartyTopology, error) {
authCtx := c.ledger.AuthContext(ctx)

pubKey := &lapiv2.SigningPublicKey{
Format: lapiv2.CryptoKeyFormat_CRYPTO_KEY_FORMAT_DER_X509_SUBJECT_PUBLIC_KEY_INFO,
KeyData: spkiPublicKey,
KeySpec: lapiv2.SigningKeySpec_SIGNING_KEY_SPEC_EC_SECP256K1,
}

topoResp, err := c.ledger.PartyAdmin().GenerateExternalPartyTopology(authCtx, &adminv2.GenerateExternalPartyTopologyRequest{
Synchronizer: c.cfg.DomainID,
PartyHint: hint,
PublicKey: pubKey,
})
if err != nil {
return nil, fmt.Errorf("generate external party topology: %w", err)
}

return &ExternalPartyTopology{
TopologyTransactions: topoResp.TopologyTransactions,
MultiHash: topoResp.MultiHash,
Fingerprint: topoResp.PublicKeyFingerprint,
}, nil
}

func (c *Client) AllocateExternalPartyWithSignature(
ctx context.Context, topology *ExternalPartyTopology, derSignature []byte,
) (*Party, error) {
authCtx := c.ledger.AuthContext(ctx)

multiHashSig := &lapiv2.Signature{
Format: lapiv2.SignatureFormat_SIGNATURE_FORMAT_DER,
Signature: derSignature,
SignedBy: topology.Fingerprint,
SigningAlgorithmSpec: lapiv2.SigningAlgorithmSpec_SIGNING_ALGORITHM_SPEC_EC_DSA_SHA_256,
}

signedTxs := make([]*adminv2.AllocateExternalPartyRequest_SignedTransaction, len(topology.TopologyTransactions))
for i, tx := range topology.TopologyTransactions {
signedTxs[i] = &adminv2.AllocateExternalPartyRequest_SignedTransaction{
Transaction: tx,
}
}

allocResp, err := c.ledger.PartyAdmin().AllocateExternalParty(authCtx, &adminv2.AllocateExternalPartyRequest{
Synchronizer: c.cfg.DomainID,
OnboardingTransactions: signedTxs,
MultiHashSignatures: []*lapiv2.Signature{multiHashSig},
})
if err != nil {
return nil, fmt.Errorf("allocate external party: %w", err)
}

c.logger.Info("Allocated external party with client signature",
zap.String("party_id", allocResp.PartyId),
zap.String("key_fingerprint", topology.Fingerprint))

return &Party{
PartyID: allocResp.PartyId,
IsLocal: false,
}, nil
}

func (c *Client) ListParties(ctx context.Context) ([]*Party, error) {
authCtx := c.ledger.AuthContext(ctx)

Expand Down
12 changes: 11 additions & 1 deletion pkg/cantonsdk/identity/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package identity

import "errors"
import (
"errors"
)

// Party contains the result of allocating a new Canton party.
type Party struct {
Expand All @@ -17,6 +19,14 @@ type FingerprintMapping struct {
EvmAddress string
}

// ExternalPartyTopology holds the intermediate state from GenerateExternalPartyTopology
// needed to complete external party allocation with a client-provided signature.
type ExternalPartyTopology struct {
TopologyTransactions [][]byte // Serialized topology transactions
MultiHash []byte // Hash to be signed by the party's key
Fingerprint string // Canton key fingerprint (multihash of SPKI public key)
}

// CreateFingerprintMappingRequest contains inputs for creating a FingerprintMapping.
type CreateFingerprintMappingRequest struct {
UserParty string
Expand Down
Loading
Loading