Skip to content

feat: add non-custodial signing flow (key_mode, prepare/execute API, external registration)#152

Open
salindne wants to merge 18 commits intomainfrom
feature/111-a-db-and-b-sdk
Open

feat: add non-custodial signing flow (key_mode, prepare/execute API, external registration)#152
salindne wants to merge 18 commits intomainfrom
feature/111-a-db-and-b-sdk

Conversation

@salindne
Copy link
Contributor

@salindne salindne commented Mar 9, 2026

Summary

Complete non-custodial signing feature (#111). Combines all sub-issues (#131#135).

Database & Domain Model

  • Add key_mode (VARCHAR, default custodial) and canton_public_key_fingerprint (TEXT) columns to users table
  • Add KeyMode, CantonPublicKeyFingerprint fields, NewExternal() constructor, key mode constants

Canton SDK (stateless)

  • Add PrepareTransfer/ExecuteTransfer to Token interface
  • SDK is stateless — no in-memory cache or goroutines at the SDK layer
  • Add validate() methods on request types, CompressedKeyToSPKI for secp256k1 SPKI encoding
  • Add CategoryGone (HTTP 410) for expired resources

Non-Custodial Transfer API

  • POST /api/v2/transfer/prepare — builds Canton transaction, returns hash for external signing
  • POST /api/v2/transfer/execute — completes transfer with client-provided DER signature
  • TransferService owns PreparedTransferCache (TTL + atomic GetAndDelete for replay prevention)
  • Allowed token symbols sourced from config (not hardcoded)
  • Strict JSON parsing with DisallowUnknownFields()

Two-Step External Registration

  • POST /register/prepare-topology — returns topology transaction hash for external signing
  • POST /register with key_mode=external — completes registration with Canton topology signature
  • TopologyCache for caching pending topology transactions
  • Whitelist check runs before any registration path (defense-in-depth)

Integration Test & Docs

  • scripts/testing/test-prepare-execute.go — end-to-end prepare/execute test script
  • docs/non-custodial-snap-testing.md — Snap testing guide

Test plan

  • go test ./... — all tests pass
  • make lint — passes
  • DB migration: go run ./cmd/api-server/migrate/main.go -config config.yaml up
  • End-to-end: go run scripts/testing/test-prepare-execute.go

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly advances the non-custodial signing feature by introducing the necessary infrastructure for external key management during token transfers. It includes database schema changes to track user key modes, extends the Canton SDK with new PrepareTransfer and ExecuteTransfer functionalities, and integrates an in-memory cache to securely manage prepared transfers. These changes enable users to initiate transfers that require external signatures, enhancing security and flexibility in token operations.

Highlights

  • Database Schema Update: Added key_mode and canton_public_key_fingerprint columns to the users table to support different key management modes.
  • Non-Custodial Transfer SDK: Introduced PrepareTransfer and ExecuteTransfer methods in the Canton SDK for facilitating transfers where the signing key is managed externally.
  • In-Memory Transfer Cache: Implemented a PreparedTransferCache with TTL and atomic GetAndDelete to manage prepared non-custodial transfers and prevent replay attacks.
  • User Model Enhancement: Updated the User domain model and DAO to include KeyMode and CantonPublicKeyFingerprint, along with a new constructor for external users.
  • New Error Category: Added CategoryGone (HTTP 410) and its corresponding error constructor for handling expired or unavailable resources.
  • Cryptographic Helper: Provided a utility function to marshal secp256k1 public keys into DER-encoded X.509 SubjectPublicKeyInfo format.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • pkg/app/errors/errors.go
    • Added CategoryGone constant.
    • Added GoneError function for creating CategoryGone errors.
    • Updated StatusCode mapping for CategoryGone.
  • pkg/cantonsdk/client/client.go
    • Updated New and NewFromAppConfig functions to optionally accept a PreparedTransferCache.
  • pkg/cantonsdk/client/options.go
    • Added preparedCache field to settings struct.
    • Added WithPreparedTransferCache option function.
  • pkg/cantonsdk/token/cache.go
    • Added PreparedTransferCache for in-memory storage of prepared transfers.
    • Implemented Put, GetAndDelete, Start, and cleanup methods for the cache.
    • Defined ErrTransferNotFound and ErrTransferExpired errors.
  • pkg/cantonsdk/token/cache_test.go
    • Added unit tests for PreparedTransferCache covering put, get, delete, expiration, cleanup, and context cancellation.
  • pkg/cantonsdk/token/client.go
    • Extended Token interface with PrepareTransfer and ExecuteTransfer methods.
    • Added preparedCache field to Client struct.
    • Modified New to initialize preparedCache.
    • Refactored transferViaFactory to extract buildTransferCommand.
    • Implemented PrepareTransfer to build and cache transaction hashes for external signing.
    • Implemented ExecuteTransfer to complete transfers with client-provided signatures.
  • pkg/cantonsdk/token/options.go
    • Added preparedCache field to settings struct.
    • Added WithPreparedTransferCache option function.
  • pkg/cantonsdk/token/types.go
    • Imported interactivev2 package.
    • Defined PrepareTransferRequest, PreparedTransfer, and ExecuteTransferRequest structs.
  • pkg/keys/canton_keys.go
    • Added Secp256k1 function to return the secp256k1 elliptic curve.
    • Added MarshalSPKIPublicKey function for DER-encoding secp256k1 public keys.
  • pkg/migrations/apidb/11_add_key_mode.go
    • Added a new database migration to add key_mode (VARCHAR, default 'custodial') and canton_public_key_fingerprint (TEXT) columns to the users table.
  • pkg/token/mocks/mock_canton_token.go
    • Added mock implementations for PrepareTransfer and ExecuteTransfer methods.
  • pkg/user/user.go
    • Defined KeyModeCustodial and KeyModeExternal constants.
    • Added KeyMode and CantonPublicKeyFingerprint fields to the User struct.
    • Updated New constructor to set KeyModeCustodial.
    • Added NewExternal constructor for non-custodial users.
  • pkg/userstore/model.go
    • Added KeyMode and CantonPublicKeyFingerprint fields to UserDao.
    • Modified toUserDao and toUser conversion functions to handle new fields and default KeyMode.
Activity
  • No specific activity (comments, reviews, or progress updates) was provided in the pull request context.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces significant features for non-custodial signing, including PrepareTransfer and ExecuteTransfer methods in the Canton SDK, a new in-memory cache for prepared transfers, and database schema updates for user key management. However, it introduces a potential Denial of Service (DoS) vector due to an unbounded in-memory cache and an Information Disclosure risk (IDOR) in the PrepareTransfer method due to a lack of authorization checks. Additionally, there are suggestions to enhance consistency and robustness by adding validation methods to the new request types, aligning them with existing patterns in the codebase.

Comment on lines +678 to +692
func (c *Client) PrepareTransfer(ctx context.Context, req *PrepareTransferRequest) (*PreparedTransfer, error) {
if c.preparedCache == nil {
return nil, fmt.Errorf("prepared transfer cache not configured")
}
if req.FromPartyID == "" || req.ToPartyID == "" {
return nil, fmt.Errorf("from/to party is required")
}
if req.Amount == "" {
return nil, fmt.Errorf("amount is required")
}
if req.TokenSymbol == "" {
return nil, fmt.Errorf("token symbol is required")
}

holdings, err := c.GetHoldings(ctx, req.FromPartyID, req.TokenSymbol)
Copy link
Contributor

Choose a reason for hiding this comment

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

security-medium medium

The PrepareTransfer method has an Insecure Direct Object Reference (IDOR) vulnerability. It allows specifying any FromPartyID and fetches holdings without verifying if the authenticated user is authorized for that FromPartyID, which could allow an attacker to probe token holdings. To address this, implement an authorization check within a validate() method for PrepareTransferRequest to ensure the FromPartyID matches the authenticated user. This also aligns with existing patterns for consistency and robustness, similar to other request types like MintRequest and BurnRequest.

func (c *Client) PrepareTransfer(ctx context.Context, req *PrepareTransferRequest) (*PrepareTransferResponse, error) {
	// TODO: Add authorization check for FromPartyID

	// Existing code for PrepareTransfer
	// ...
	return &PrepareTransferResponse{/* ... */}, nil
}

func (c *Client) ExecuteTransfer(ctx context.Context, req *ExecuteTransferRequest) error {
if c.preparedCache == nil {
return fmt.Errorf("prepared transfer cache not configured")
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

To improve robustness and consistency, consider adding a validate() method to ExecuteTransferRequest to ensure all required fields are present before use. This helps fail early with clear error messages.

You can add the following method to pkg/cantonsdk/token/types.go:

func (r *ExecuteTransferRequest) validate() error {
	if r.TransferID == "" {
		return fmt.Errorf("transfer ID is required")
	}
	if len(r.Signature) == 0 {
		return fmt.Errorf("signature is required")
	}
	if r.SignedBy == "" {
		return fmt.Errorf("signed by is required")
	}
	return nil
}

Then, you can call it here at the beginning of the function.

	if err := req.validate(); err != nil {
		return fmt.Errorf("invalid request: %w", err)
	}

@salindne salindne force-pushed the feature/111-a-db-and-b-sdk branch from 8539a96 to ff8cd65 Compare March 10, 2026 19:56
Add database migration for key_mode and canton_public_key_fingerprint
columns. Extend User domain model with KeyMode and NewExternal
constructor for non-custodial users. Add CategoryGone error type.

Canton SDK: add PrepareTransfer/ExecuteTransfer to Token interface with
in-memory PreparedTransferCache (TTL + atomic GetAndDelete for replay
prevention). Extract buildTransferCommand helper from transferViaFactory.

Add CompressedKeyToSPKI helper for secp256k1 public key encoding.
@salindne salindne force-pushed the feature/111-a-db-and-b-sdk branch from ff8cd65 to 33e124a Compare March 13, 2026 20:28
Move cache responsibility out of the SDK client — PrepareTransfer
now returns the result without caching, and ExecuteTransfer accepts
the full PreparedTransfer in the request. The service layer (added
in next PR) will own the cache lifecycle.

Also add validate() methods to PrepareTransferRequest and
ExecuteTransferRequest for defense-in-depth input checking.
New pkg/transfer package with thin service layer for the two-step
non-custodial transfer flow:
- POST /api/v2/transfer/prepare — validates inputs, resolves parties,
  calls SDK PrepareTransfer, returns transaction hash for client signing
- POST /api/v2/transfer/execute — verifies fingerprint match, decodes
  DER signature, calls SDK ExecuteTransfer

Auth uses existing EVM signature verification (X-Signature/X-Message)
with timed message validation (5-minute replay window).

Input validation: EVM address format, positive decimal amount, token
allowlist (DEMO/PROMPT). Rejects custodial users with 400.

Wire transfer routes and cache into api/server.go.
- TransferService now owns the PreparedTransferCache (SDK is stateless)
- Allowed token symbols are derived from token config instead of hardcoded
- readJSON uses json.NewDecoder with DisallowUnknownFields for stricter parsing
Support non-custodial (external) user registration via two HTTP calls:

1. POST /register/prepare-topology — verify EIP-191 signature, validate
   whitelist, derive SPKI public key from compressed secp256k1 key,
   call Canton GenerateExternalPartyTopology, return topology hash +
   fingerprint + registration token for client-side signing.

2. POST /register (key_mode=external) — retrieve pending topology from
   cache, verify SPKI key consistency between steps, call Canton
   AllocateExternalPartyWithSignature with client's DER signature,
   create fingerprint mapping, save user as key_mode=external.

Canton SDK: split AllocateExternalParty into GenerateExternalPartyTopology
and AllocateExternalPartyWithSignature for the two-step flow.

Add TopologyCache (in-memory, 5-min TTL) for pending registrations.
Add PrepareTopologyResponse and external fields to RegisterRequest.
Extend logging decorator with PrepareExternalRegistration wrapper.
The whitelist check was after the KeyModeExternal early return,
meaning external registrations bypassed it. While PrepareExternalRegistration
also checks the whitelist, defense-in-depth requires the check at both
entry points.
Integration test script (test-prepare-execute.go) covering:
- Happy path: register external user, mint, prepare, sign, execute
- Expired transfer (past TTL → 410)
- Replay prevention (double execute → 404)
- Wrong fingerprint (→ 403)
- Custodial rejection (→ 400)

Add docs/non-custodial-snap-testing.md with complete Snap integration
guide: cryptographic requirements, API reference, error tables, RPC
methods, JavaScript dapp example, and curl testing commands.

Update e2e-local config with test user addresses.
…ment

Update mock expectations in registration tests to match the new
whitelist-before-UserExists ordering. Also remove no-op `_ = hash`
in test script.
@salindne salindne force-pushed the feature/111-a-db-and-b-sdk branch from d763330 to e9b0910 Compare March 17, 2026 17:44
@salindne
Copy link
Contributor Author

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces significant functionality for non-custodial signing by adding PrepareTransfer and ExecuteTransfer methods to the Canton SDK, along with an in-memory cache for prepared transfers to enhance security. The database schema is appropriately updated to distinguish between custodial and external key modes. The changes are well-implemented, including necessary domain model updates, database migrations, and unit tests for the new caching logic. My feedback includes a couple of suggestions to remove redundant code for improved maintainability.

Comment on lines 68 to 70
if usr.KeyMode == "" {
usr.KeyMode = user.KeyModeCustodial
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The database migration 11_add_key_mode.go defines the key_mode column as NOT NULL DEFAULT 'custodial'. This ensures that dao.KeyMode will never be an empty string when a UserDao is populated from the database. Therefore, this check is redundant and can be safely removed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in the same commit — removed from both toUserDao and toUser.

feat: add non-custodial integration test and Snap docs (#135)
feat: add two-step external user registration (#134)
feat: add non-custodial transfer HTTP API (#133)
@salindne
Copy link
Contributor Author

/gemini review

@gemini-code-assist
Copy link
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@salindne
Copy link
Contributor Author

/gemini review

@salindne salindne changed the title feat: add key_mode DB column and SDK prepare/execute transfer (#131, #132) feat: add non-custodial signing flow (key_mode, prepare/execute API, external registration) Mar 17, 2026
Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a significant feature for non-custodial transfers, including a two-step prepare/execute flow, database schema updates, and new API endpoints. The implementation is comprehensive, with new services, caches, and extensive documentation. My review has identified a critical issue related to the handling of transfer expiration TTLs, where a hardcoded value in the SDK overrides the configured cache TTL. I've also noted a couple of areas in the new documentation that could be clarified to prevent ambiguity for developers. Overall, this is a well-structured addition, and addressing these points will enhance its robustness and clarity.

- Move ExpiresAt assignment from SDK to cache.Put() so TTL is
  controlled by the cache owner (2min) instead of SDK (1h)
- Add maxSize cap (10k) to PreparedTransferCache to prevent OOM
- cache.Put() now returns error when cache is full
- Remove redundant KeyMode defaulting in userstore model (DB default
  handles it)
- Remove migration 11_add_key_mode.go (not in prod, schema created
  from struct)
- Fix docs: clarify which fingerprint is used for signed_by
…rator

- Extract TopologyCacheProvider interface from concrete TopologyCache
- Export PendingTopology to user package (avoids import cycle with mocks)
- Extract TransferCache interface from concrete PreparedTransferCache
- Extract transfer.Service interface from TransferService
- Add transfer/log.go logging decorator (matches user/service/log.go pattern)
- Wire transfer.NewLog decorator in API server setup
- Add TopologyCacheProvider mock for testing
- PrepareExternalRegistration: missing public key, user exists, not whitelisted
- TransferService.Prepare: success, missing fields, sender not found,
  sender not external, unsupported token
- TransferService.Execute: success, missing fields, transfer not found,
  transfer expired
- Move field-presence checks (empty To/Amount/Token, empty TransferID/
  Signature/SignedBy) from transfer service to http.go
- Move external registration field checks (RegistrationToken,
  TopologySignature, CantonPublicKey) from service to http.go
- Remove duplicate CantonPublicKey check in PrepareExternalRegistration
  (already validated in HTTP handler)
- Keep domain validation (allowed token symbols, key mode checks) in
  service layer
- Fix lint: extract testMessage constant, embed token.Token interface
  in mock, fix goimports ordering, break long line in server.go
@salindne
Copy link
Contributor Author

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

The pull request introduces a comprehensive non-custodial signing flow, including two-step registration and transfer APIs. The changes span across the domain model, Canton SDK, API handlers, and include new caching mechanisms and an integration test script. The documentation for the new flow is clear and detailed. The implementation demonstrates good practices for input validation, authentication, and logging of sensitive data. Overall, the feature is well-implemented and tested.

const defaultRequestTimeout = 60
const (
defaultRequestTimeout = 60
topologyCacheTTL = 5 * time.Minute
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The topologyCacheTTL is a magic number. It would be better to define this as a configurable value (e.g., in the config.yaml) or as a clearly named constant if it's a fixed domain-specific requirement, with a comment explaining its significance.

defer stopReconcile()

topologyCache := userservice.NewTopologyCache(topologyCacheTTL)
go topologyCache.Start(ctx)
Copy link
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.


router := s.setupRouter(evmStore, cantonClient, tokenService, userservice.NewLog(registrationService, logger), logger)
transferSvc := transfer.NewTransferService(cantonClient.Token, userStore, tokenSymbols(cfg.Token))
go transferSvc.StartCache(ctx)
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Similar to the topologyCache, the transferSvc.StartCache(ctx) is launched as a goroutine without explicit panic recovery. A panic within this goroutine could lead to an unhandled application crash. It's recommended to add a defer with recover() to ensure graceful handling of unexpected panics.

ErrCacheFull = errors.New("cache is full")
)

const defaultCleanupInterval = 30 * time.Second
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The defaultCleanupInterval is a magic number. It would be better to define this as a configurable value or as a clearly named constant with a comment explaining its significance.


// Start runs a background goroutine that periodically removes expired entries.
// It stops when the context is canceled.
func (c *PreparedTransferCache) Start(ctx context.Context) {
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The Start method launches a goroutine without explicit panic recovery. While the cleanup logic itself appears safe, a panic originating from within this goroutine could lead to an unhandled application crash. Consider adding a defer with recover() to log and handle such panics gracefully.


const (
maxRequestBodyBytes = 1 << 20 // 1MB
messageMaxAge = 5 * time.Minute
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The messageMaxAge constant is a magic number. It would be better to define this as a configurable value or as a clearly named constant with a comment explaining its significance.


const (
transferServiceName = "TransferService"
signatureDisplaySize = 16
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The signatureDisplaySize constant is a magic number. It would be better to define this as a clearly named constant with a comment explaining its significance.

Comment on lines +17 to +18
defaultCacheTTL = 2 * time.Minute
defaultCacheMaxSize = 10000
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The defaultCacheTTL and defaultCacheMaxSize constants are magic numbers. It would be better to define these as configurable values (e.g., in the config.yaml) or as clearly named constants with comments explaining their significance.

Comment on lines +19 to +20
defaultTopologyTTL = 5 * time.Minute
topologyCleanupInterval = 30 * time.Second
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The defaultTopologyTTL and topologyCleanupInterval constants are magic numbers. It would be better to define these as configurable values or as clearly named constants with comments explaining their significance.

}

// Start runs a background goroutine that periodically removes expired entries.
func (c *TopologyCache) Start(ctx context.Context) {
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The Start method launches a goroutine without explicit panic recovery. While the cleanup logic itself appears safe, a panic originating from within this goroutine could lead to an unhandled application crash. Consider adding a defer with recover() to log and handle such panics gracefully.

elliptic.UnmarshalCompressed does not work with go-ethereum's secp256k1
curve implementation, causing all external registration requests to fail
with "invalid canton_public_key". Switch to crypto.DecompressPubkey which
is consistent with the rest of the codebase.

Also fix compile errors in test-prepare-execute.go (double pointer on
Database config, non-existent canton.NewFromAppConfig function).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants