From a641fd536f0382cc7d093e9814d1708efbab898f Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Fri, 8 May 2026 12:17:45 +0700 Subject: [PATCH 1/2] feat: transfer TRUF inside the network --- core/contractsapi/bridge_actions.go | 35 +++++++++++++++++++ core/tnclient/actions_transport.go | 4 +++ core/tnclient/client.go | 10 ++++++ core/types/stream.go | 5 +++ core/types/tsn_client.go | 3 ++ docs/api-reference.md | 32 ++++++++++++++++++ tests/integration/bridge_actions_test.go | 43 ++++++++++++++++++++++-- 7 files changed, 130 insertions(+), 2 deletions(-) diff --git a/core/contractsapi/bridge_actions.go b/core/contractsapi/bridge_actions.go index 6186ab7..f61b7db 100644 --- a/core/contractsapi/bridge_actions.go +++ b/core/contractsapi/bridge_actions.go @@ -65,3 +65,38 @@ func (s *Action) Withdraw(ctx context.Context, bridgeIdentifier string, amount s return txHash.String(), nil } + +// Transfer sends TRUF (or USDC, on bridges that support it) from the caller +// to another in-network wallet. Binds to the on-chain action +// "_transfer" — e.g. "eth_truf" → eth_truf_transfer (mainnet), +// "ethereum" → ethereum_transfer / "sepolia" → sepolia_transfer (dev/test). +// +// The caller pays a 1-token action fee on top of `amount`, in the same token +// as the bridge instance. Reverts if the caller balance is below +// (amount + 1 token). +func (s *Action) Transfer(ctx context.Context, bridgeIdentifier string, recipient string, amount string) (string, error) { + if bridgeIdentifier == "" { + return "", errors.New("bridge identifier is required") + } + if recipient == "" { + return "", errors.New("recipient address is required") + } + if amount == "" { + return "", errors.New("amount is required") + } + if _, _, err := apd.NewFromString(amount); err != nil { + return "", fmt.Errorf("invalid amount format: %w", err) + } + + actionName := bridgeIdentifier + "_transfer" + + // Action signature: ($to_address TEXT, $amount TEXT) + args := []any{recipient, amount} + + txHash, err := s.execute(ctx, actionName, [][]any{args}) + if err != nil { + return "", err + } + + return txHash.String(), nil +} diff --git a/core/tnclient/actions_transport.go b/core/tnclient/actions_transport.go index 8f4ae36..12463e2 100644 --- a/core/tnclient/actions_transport.go +++ b/core/tnclient/actions_transport.go @@ -190,6 +190,10 @@ func (a *TransportAction) Withdraw(ctx context.Context, bridgeIdentifier string, return "", fmt.Errorf("Withdraw not implemented for custom transports - use HTTP transport or implement if needed") } +func (a *TransportAction) Transfer(ctx context.Context, bridgeIdentifier string, recipient string, amount string) (string, error) { + return "", fmt.Errorf("Transfer not implemented for custom transports - use HTTP transport or implement if needed") +} + func (a *TransportAction) GetWithdrawalProof(ctx context.Context, input clientType.GetWithdrawalProofInput) ([]clientType.WithdrawalProof, error) { return nil, fmt.Errorf("GetWithdrawalProof not implemented for custom transports - use HTTP transport or implement if needed") } diff --git a/core/tnclient/client.go b/core/tnclient/client.go index f06a779..6dd5cfb 100644 --- a/core/tnclient/client.go +++ b/core/tnclient/client.go @@ -481,6 +481,16 @@ func (c *Client) Withdraw(ctx context.Context, bridgeIdentifier string, amount s return actions.Withdraw(ctx, bridgeIdentifier, amount, recipient) } +// Transfer sends tokens from the caller to another in-network wallet via the +// bridge's public transfer action. Costs a 1-token fee in the same token as the bridge. +func (c *Client) Transfer(ctx context.Context, bridgeIdentifier string, recipient string, amount string) (string, error) { + actions, err := c.LoadActions() + if err != nil { + return "", errors.Wrap(err, "failed to load actions for Transfer") + } + return actions.Transfer(ctx, bridgeIdentifier, recipient, amount) +} + // GetWithdrawalProof retrieves the proofs and signatures needed to claim a withdrawal on EVM. func (c *Client) GetWithdrawalProof(ctx context.Context, input clientType.GetWithdrawalProofInput) ([]clientType.WithdrawalProof, error) { actions, err := c.LoadActions() diff --git a/core/types/stream.go b/core/types/stream.go index f250e7e..d1145b5 100644 --- a/core/types/stream.go +++ b/core/types/stream.go @@ -145,6 +145,11 @@ type IAction interface { // Withdraw performs a withdrawal operation by bridging tokens from TN to a destination chain Withdraw(ctx context.Context, bridgeIdentifier string, amount string, recipient string) (string, error) + // Transfer sends tokens from the caller to another in-network wallet via the + // bridge's public transfer action ("_transfer"). Costs a + // 1-token fee on top of `amount`, paid in the same token as the bridge. + Transfer(ctx context.Context, bridgeIdentifier string, recipient string, amount string) (string, error) + // GetWithdrawalProof retrieves the proofs and signatures needed to claim a withdrawal on EVM. GetWithdrawalProof(ctx context.Context, input GetWithdrawalProofInput) ([]WithdrawalProof, error) } diff --git a/core/types/tsn_client.go b/core/types/tsn_client.go index b7243c6..3e7f294 100644 --- a/core/types/tsn_client.go +++ b/core/types/tsn_client.go @@ -53,6 +53,9 @@ type Client interface { GetWalletBalance(ctx context.Context, bridgeIdentifier string, walletAddress string) (string, error) // Withdraw performs a withdrawal operation by bridging tokens from TN to a destination chain Withdraw(ctx context.Context, bridgeIdentifier string, amount string, recipient string) (string, error) + // Transfer sends tokens from the caller to another in-network wallet via the + // bridge's public transfer action. Costs a 1-token fee in the same token as the bridge. + Transfer(ctx context.Context, bridgeIdentifier string, recipient string, amount string) (string, error) // GetWithdrawalProof retrieves the proofs and signatures needed to claim a withdrawal on EVM. GetWithdrawalProof(ctx context.Context, input GetWithdrawalProofInput) ([]WithdrawalProof, error) } diff --git a/docs/api-reference.md b/docs/api-reference.md index 765ab5f..8af3747 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -2611,6 +2611,38 @@ fmt.Printf("Burn TX Hash: %s\n", txHash) --- +#### `Transfer` + +Sends tokens from the caller to another in-network wallet via the bridge's public transfer action. Binds to the on-chain action `_transfer` — `eth_truf_transfer` / `eth_usdc_transfer` on mainnet, `ethereum_transfer` / `sepolia_transfer` on dev/test. + +The caller pays a **1-token action fee** on top of `amount`, denominated in the same token as the bridge (1 TRUF for `eth_truf`, 1 USDC for `eth_usdc`). The action reverts if the caller balance is below `amount + 1 token`. + +**Signature:** +```go +func (c *Client) Transfer(ctx context.Context, bridgeIdentifier string, recipient string, amount string) (string, error) +``` + +**Parameters:** +- `bridgeIdentifier` (string): Bridge / action namespace prefix (e.g. `"eth_truf"`, `"eth_usdc"`, `"sepolia"`). +- `recipient` (string): Destination wallet address (Ethereum 0x… format). +- `amount` (string): Transfer amount in wei. Must be a valid numeric string. + +**Returns:** +- `string`: Transaction hash of the transfer. +- `error`: Error if validation fails or the transaction is rejected. + +**Example — Refill bot pattern:** +```go +// Top up an adapter wallet to its threshold; budget +1 TRUF for the action fee. +txHash, err := client.Transfer(ctx, "eth_truf", adapterWallet, "100000000000000000000") +if err != nil { + log.Fatal(err) +} +fmt.Printf("Refill TX Hash: %s\n", txHash) +``` + +--- + #### `GetWithdrawalProof` Retrieves the cryptographic proofs required to claim a withdrawal on the destination chain (e.g., via the `withdraw` function on the bridge contract). diff --git a/tests/integration/bridge_actions_test.go b/tests/integration/bridge_actions_test.go index 278dab9..aae7ab5 100644 --- a/tests/integration/bridge_actions_test.go +++ b/tests/integration/bridge_actions_test.go @@ -79,7 +79,26 @@ func TestBridgeActions(t *testing.T) { t.Logf("GetWithdrawalProof Response: %v", err) }) - // Test 4: Input Validation + // Test 4: Transfer + // Like Withdraw, Transfer is an async write — node may accept the tx into the + // mempool and surface validation errors at block time. So we accept either + // an immediate error or a non-empty hash. + t.Run("Transfer", func(t *testing.T) { + bridgeID := "non_existent_bridge" + recipient := "0x1234567890123456789012345678901234567890" + amount := "1000000000000000000" // 1 token + + hash, err := tnClient.Transfer(ctx, bridgeID, recipient, amount) + + if err != nil { + t.Logf("Transfer returned error (acceptable): %v", err) + } else { + require.NotEmpty(t, hash, "must return a non-empty transaction hash when no error") + t.Logf("Transfer returned hash (acceptable): %v", hash) + } + }) + + // Test 5: Input Validation t.Run("InputValidation", func(t *testing.T) { // Withdraw Empty Bridge _, err := tnClient.Withdraw(ctx, "", "100", "0x123") @@ -90,7 +109,27 @@ func TestBridgeActions(t *testing.T) { _, err = tnClient.Withdraw(ctx, "bridge", "invalid_amount", "0x123") require.Error(t, err) // Error might come from the node or SDK decimal parsing - + + // Transfer Empty Bridge + _, err = tnClient.Transfer(ctx, "", "0x123", "100") + require.Error(t, err) + assert.Contains(t, err.Error(), "bridge identifier is required") + + // Transfer Empty Recipient + _, err = tnClient.Transfer(ctx, "bridge", "", "100") + require.Error(t, err) + assert.Contains(t, err.Error(), "recipient address is required") + + // Transfer Empty Amount + _, err = tnClient.Transfer(ctx, "bridge", "0x123", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "amount is required") + + // Transfer Invalid Amount + _, err = tnClient.Transfer(ctx, "bridge", "0x123", "not_a_number") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid amount format") + // GetWithdrawalProof Empty Bridge _, err = tnClient.GetWithdrawalProof(ctx, types.GetWithdrawalProofInput{ BridgeIdentifier: "", From 8f868d95d6d5ad29c6b09980df7905e9c6da6516 Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Fri, 8 May 2026 16:21:37 +0700 Subject: [PATCH 2/2] chore: apply suggestion --- core/contractsapi/bridge_actions.go | 12 +++++++++++- docs/api-reference.md | 12 +++++++----- tests/integration/bridge_actions_test.go | 24 +++++++++++++++++++++--- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/core/contractsapi/bridge_actions.go b/core/contractsapi/bridge_actions.go index f61b7db..d6a9107 100644 --- a/core/contractsapi/bridge_actions.go +++ b/core/contractsapi/bridge_actions.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/cockroachdb/apd/v3" + "github.com/trufnetwork/sdk-go/core/util" ) // GetWalletBalance retrieves the wallet balance for a specific bridge instance @@ -81,12 +82,21 @@ func (s *Action) Transfer(ctx context.Context, bridgeIdentifier string, recipien if recipient == "" { return "", errors.New("recipient address is required") } + if _, err := util.NewEthereumAddressFromString(recipient); err != nil { + return "", fmt.Errorf("invalid recipient address format: %w", err) + } if amount == "" { return "", errors.New("amount is required") } - if _, _, err := apd.NewFromString(amount); err != nil { + parsed, _, err := apd.NewFromString(amount) + if err != nil { return "", fmt.Errorf("invalid amount format: %w", err) } + // On-chain action enforces amount > 0; mirror it here to fail fast and + // avoid spending a tx submission round-trip on guaranteed reverts. + if parsed.Sign() <= 0 { + return "", fmt.Errorf("amount must be > 0, got %s", amount) + } actionName := bridgeIdentifier + "_transfer" diff --git a/docs/api-reference.md b/docs/api-reference.md index 8af3747..9732c71 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -2613,7 +2613,7 @@ fmt.Printf("Burn TX Hash: %s\n", txHash) #### `Transfer` -Sends tokens from the caller to another in-network wallet via the bridge's public transfer action. Binds to the on-chain action `_transfer` — `eth_truf_transfer` / `eth_usdc_transfer` on mainnet, `ethereum_transfer` / `sepolia_transfer` on dev/test. +Sends tokens from the caller to another in-network wallet via the bridge's public transfer action. The caller pays a **1-token action fee** on top of `amount`, denominated in the same token as the bridge (1 TRUF for `eth_truf`, 1 USDC for `eth_usdc`). The action reverts if the caller balance is below `amount + 1 token`. @@ -2622,14 +2622,16 @@ The caller pays a **1-token action fee** on top of `amount`, denominated in the func (c *Client) Transfer(ctx context.Context, bridgeIdentifier string, recipient string, amount string) (string, error) ``` +> **Note:** Callers must pass a supported `bridgeIdentifier` — `"eth_truf"` or `"eth_usdc"` on mainnet, `"sepolia"` on dev/test. `Client.Transfer` is **not implemented for custom transports** (e.g. CRE); when used through a non-HTTP transport it returns a runtime error. Use the default HTTP transport, or implement `Transfer` on your custom transport. + **Parameters:** -- `bridgeIdentifier` (string): Bridge / action namespace prefix (e.g. `"eth_truf"`, `"eth_usdc"`, `"sepolia"`). -- `recipient` (string): Destination wallet address (Ethereum 0x… format). -- `amount` (string): Transfer amount in wei. Must be a valid numeric string. +- `bridgeIdentifier` (string): Supported bridge namespace — `"eth_truf"`, `"eth_usdc"` (mainnet) or `"sepolia"` (dev/test). +- `recipient` (string): Destination wallet address (0x-prefixed 40-char hex). Validated client-side. +- `amount` (string): Transfer amount in wei. Must parse as a positive decimal. **Returns:** - `string`: Transaction hash of the transfer. -- `error`: Error if validation fails or the transaction is rejected. +- `error`: Error if validation fails (empty / invalid recipient, non-positive or non-numeric amount), the custom transport doesn't implement `Transfer`, or the transaction is rejected. **Example — Refill bot pattern:** ```go diff --git a/tests/integration/bridge_actions_test.go b/tests/integration/bridge_actions_test.go index aae7ab5..647dc4c 100644 --- a/tests/integration/bridge_actions_test.go +++ b/tests/integration/bridge_actions_test.go @@ -110,8 +110,11 @@ func TestBridgeActions(t *testing.T) { require.Error(t, err) // Error might come from the node or SDK decimal parsing + // A valid 0x-prefixed address used for cases that test amount validation. + validRecipient := "0x9160BBD07295b77BB168FF6295D66C74E575B5BE" + // Transfer Empty Bridge - _, err = tnClient.Transfer(ctx, "", "0x123", "100") + _, err = tnClient.Transfer(ctx, "", validRecipient, "100") require.Error(t, err) assert.Contains(t, err.Error(), "bridge identifier is required") @@ -120,16 +123,31 @@ func TestBridgeActions(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "recipient address is required") + // Transfer Invalid Recipient (wrong length / not hex) + _, err = tnClient.Transfer(ctx, "bridge", "0x123", "100") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid recipient address format") + // Transfer Empty Amount - _, err = tnClient.Transfer(ctx, "bridge", "0x123", "") + _, err = tnClient.Transfer(ctx, "bridge", validRecipient, "") require.Error(t, err) assert.Contains(t, err.Error(), "amount is required") // Transfer Invalid Amount - _, err = tnClient.Transfer(ctx, "bridge", "0x123", "not_a_number") + _, err = tnClient.Transfer(ctx, "bridge", validRecipient, "not_a_number") require.Error(t, err) assert.Contains(t, err.Error(), "invalid amount format") + // Transfer Zero Amount + _, err = tnClient.Transfer(ctx, "bridge", validRecipient, "0") + require.Error(t, err) + assert.Contains(t, err.Error(), "amount must be > 0") + + // Transfer Negative Amount + _, err = tnClient.Transfer(ctx, "bridge", validRecipient, "-5") + require.Error(t, err) + assert.Contains(t, err.Error(), "amount must be > 0") + // GetWithdrawalProof Empty Bridge _, err = tnClient.GetWithdrawalProof(ctx, types.GetWithdrawalProofInput{ BridgeIdentifier: "",