Skip to content

feat: add non-custodial transfer HTTP API (#133)#153

Merged
salindne merged 9 commits intofeature/111-a-db-and-b-sdkfrom
feature/111-c-http-api
Mar 17, 2026
Merged

feat: add non-custodial transfer HTTP API (#133)#153
salindne merged 9 commits intofeature/111-a-db-and-b-sdkfrom
feature/111-c-http-api

Conversation

@salindne
Copy link
Contributor

@salindne salindne commented Mar 9, 2026

Summary

Sub-issue C (#133) of the non-custodial signing feature (#111).

  • New pkg/transfer/ package: Thin service layer for two-step non-custodial transfer flow
    • POST /api/v2/transfer/prepare — validate inputs, resolve sender/recipient parties, call SDK PrepareTransfer, return transaction hash for client signing
    • POST /api/v2/transfer/execute — verify fingerprint match, decode DER signature, call SDK ExecuteTransfer
  • Auth: EVM signature verification via X-Signature/X-Message headers with timed message validation (5-minute replay window)
  • Input validation: EVM address format, positive decimal amount (shopspring/decimal), token allowlist (DEMO/PROMPT)
  • Error handling: Rejects custodial users (400), expired transfers (410), replay attempts (404), fingerprint mismatch (403)
  • Wiring: Transfer routes and cache integrated into api/server.go

Test plan

  • go build ./... — compiles
  • go test ./... — all tests pass
  • make lint — passes
  • Manual: curl -X POST /api/v2/transfer/prepare with custodial user → 400

Stacked PR 2/4 for #111 — depends on #152

@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 introduces the foundational HTTP API for non-custodial token transfers, enabling users to securely initiate and complete transactions by signing them externally. It establishes a robust two-step process, enhancing security and user control over their assets, and integrates necessary authentication and validation mechanisms into the existing API infrastructure.

Highlights

  • New Transfer Package: Implemented a new pkg/transfer/ package to manage the two-step non-custodial transfer flow.
  • New API Endpoints: Introduced POST /api/v2/transfer/prepare for validating inputs, resolving parties, and returning a transaction hash for client signing, and POST /api/v2/transfer/execute to verify signatures and complete prepared transfers.
  • EVM Signature Verification: Integrated EVM signature verification using X-Signature/X-Message headers with a 5-minute replay window for security.
  • Input Validation: Enhanced input validation for EVM addresses, positive decimal amounts, and an allowed token list (DEMO/PROMPT).
  • Error Handling: Improved error handling for various scenarios including custodial users, expired transfers, replay attempts, and fingerprint mismatches.
  • API Integration: Wired the new transfer routes and a prepared transfer cache into the api/server.go for operational readiness.

🧠 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/api/server.go
    • Imported the new pkg/transfer package.
    • Initialized a PreparedTransferCache and started it as a goroutine.
    • Modified openCantonClient to accept and utilize the transferCache.
    • Created a TransferService instance.
    • Updated setupRouter to accept the new TransferService.
    • Registered the new non-custodial transfer API routes (/api/v2/transfer/prepare, /api/v2/transfer/execute).
  • pkg/auth/evm.go
    • Added strconv and time imports.
    • Implemented ValidateTimedMessage function to prevent replay attacks by checking message timestamps.
  • pkg/transfer/http.go
    • Added a new file defining the HTTP handlers for the non-custodial transfer API.
    • Implemented prepare and execute endpoint handlers.
    • Included authenticateEVM for signature verification and readJSON/writeJSON for request/response handling.
  • pkg/transfer/service.go
    • Added a new file containing the core business logic for TransferService.
    • Implemented Prepare to validate transfer requests, resolve sender/recipient, and interact with the Canton SDK to prepare a transfer.
    • Implemented Execute to verify signatures, decode DER signatures, and complete the prepared transfer via the Canton SDK.
    • Defined allowedTokenSymbols and UserStore interface.
  • pkg/transfer/types.go
    • Added a new file defining the data structures (PrepareRequest, PrepareResponse, ExecuteRequest, ExecuteResponse) for the non-custodial transfer API.
Activity
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 a non-custodial transfer API with prepare and execute endpoints, implemented with a new transfer package for service and HTTP layers. While the overall design is sound, a critical security concern has been identified regarding the authentication mechanism: the lack of binding between the EIP-191 signature and the request body. This vulnerability could lead to request manipulation and Denial of Service (DoS) attacks. It is recommended to strengthen the authentication scheme by including a hash of the request body and enforcing action-specific prefixes in the signed messages. Further improvements include enhancing the robustness of JSON request handling and making the token allowlist configurable for better maintainability.

Comment on lines +78 to +95
func authenticateEVM(r *http.Request) (string, error) {
sig := r.Header.Get("X-Signature")
msg := r.Header.Get("X-Message")
if sig == "" || msg == "" {
return "", apperrors.UnAuthorizedError(nil, "authentication required")
}

if err := auth.ValidateTimedMessage(msg, messageMaxAge); err != nil {
return "", apperrors.UnAuthorizedError(err, "message expired or invalid format")
}

recovered, err := auth.VerifyEIP191Signature(msg, sig)
if err != nil {
return "", apperrors.UnAuthorizedError(err, "invalid signature")
}

return auth.NormalizeAddress(recovered.Hex()), nil
}
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 authenticateEVM function verifies the caller's identity using an EIP-191 signature over a message that only contains a timestamp. This has two security implications:

  1. Missing Request Body Binding: The signature does not cover the HTTP request body, allowing an attacker to replay a valid signature with modified request parameters (e.g., different recipient or amount in Prepare) on behalf of the user.
  2. Missing Action Prefix Validation: Although the documentation suggests a prefix like transfer: should be used, the code in auth.ValidateTimedMessage does not enforce it. This allows a signature intended for one action to be replayed for another if they use the same authentication scheme.

While the transfer flow is partially protected by a second layer of signing for the Canton transaction, this authentication pattern is fundamentally insecure and could lead to more severe vulnerabilities if applied to other endpoints.

Comment on lines +31 to +32
r.Post("/api/v2/transfer/prepare", apphttp.HandleError(h.prepare))
r.Post("/api/v2/transfer/execute", apphttp.HandleError(h.execute))
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 Prepare endpoint creates a new PreparedTransfer entry in an in-memory cache for every valid request. Because the authenticateEVM function allows replaying the same X-Signature and X-Message multiple times within a 5-minute window, an attacker can capture a single valid signature and use it to flood the server with a large number of Prepare requests. Since the PreparedTransferCache has no size limit, this can lead to memory exhaustion and a Denial of Service (DoS).

Comment on lines +97 to +106
func readJSON(r *http.Request, dst any) error {
body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodyBytes))
if err != nil {
return apperrors.BadRequestError(err, "failed to read request")
}
if err := json.Unmarshal(body, dst); err != nil {
return apperrors.BadRequestError(err, "invalid JSON")
}
return nil
}
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 current implementation of readJSON reads the entire request body into memory before unmarshalling. A more memory-efficient and robust approach is to use json.NewDecoder on the request body stream. This avoids a large memory allocation for the request body.

Additionally, calling dec.DisallowUnknownFields() makes the API stricter by rejecting requests with unknown JSON fields, which can help catch client-side bugs. Checking dec.More() ensures that there is no extraneous data after the main JSON object, further improving robustness.

func readJSON(r *http.Request, dst any) error {
	dec := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodyBytes))
	dec.DisallowUnknownFields()

	if err := dec.Decode(dst); err != nil {
		return apperrors.BadRequestError(err, "invalid JSON")
	}

	if dec.More() {
		return apperrors.BadRequestError(nil, "request body contains extra data")
	}

	return nil
}

Comment on lines +19 to +23
// allowedTokenSymbols defines the set of valid token symbols for transfers.
var allowedTokenSymbols = map[string]bool{
"DEMO": true,
"PROMPT": true,
}
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 list of allowed token symbols is currently hardcoded. For better maintainability and flexibility, consider making this list configurable. This could be achieved by passing the allowed symbols from the application's configuration into the TransferService during its creation. This would allow operators to update the list of supported tokens without requiring a code change and redeployment.

Copy link
Member

Choose a reason for hiding this comment

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

@salindne we can take it from token config.
Where we already have supported tokens

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 — TransferService now takes allowed symbols from token config via NewTransferService(cantonToken, userStore, allowedSymbols). The symbols are extracted from cfg.Token.SupportedTokens in server.go.

Comment on lines +19 to +23
// allowedTokenSymbols defines the set of valid token symbols for transfers.
var allowedTokenSymbols = map[string]bool{
"DEMO": true,
"PROMPT": true,
}
Copy link
Member

Choose a reason for hiding this comment

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

@salindne we can take it from token config.
Where we already have supported tokens

@salindne salindne force-pushed the feature/111-a-db-and-b-sdk branch from 8539a96 to ff8cd65 Compare March 10, 2026 19:56
@salindne salindne force-pushed the feature/111-c-http-api branch from 8b68b03 to 5e10849 Compare March 10, 2026 19:56
@salindne salindne force-pushed the feature/111-a-db-and-b-sdk branch from ff8cd65 to 33e124a Compare March 13, 2026 20:28
@salindne salindne force-pushed the feature/111-c-http-api branch 2 times, most recently from 90ad5d9 to 7c80a3f Compare March 17, 2026 17:35
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 salindne force-pushed the feature/111-c-http-api branch from 7c80a3f to 75e867d Compare March 17, 2026 17:44
feat: add non-custodial integration test and Snap docs (#135)
feat: add two-step external user registration (#134)
@salindne salindne merged commit e7092e3 into feature/111-a-db-and-b-sdk Mar 17, 2026
2 checks passed
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