Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 45 additions & 0 deletions core/contractsapi/bridge_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,3 +66,47 @@ 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
// "<bridgeIdentifier>_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 _, err := util.NewEthereumAddressFromString(recipient); err != nil {
return "", fmt.Errorf("invalid recipient address format: %w", err)
}
if amount == "" {
return "", errors.New("amount is required")
}
parsed, _, err := apd.NewFromString(amount)
if err != nil {
return "", fmt.Errorf("invalid amount format: %w", err)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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"

// 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
}
4 changes: 4 additions & 0 deletions core/tnclient/actions_transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
10 changes: 10 additions & 0 deletions core/tnclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions core/types/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ("<bridgeIdentifier>_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)
}
3 changes: 3 additions & 0 deletions core/types/tsn_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
34 changes: 34 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -2611,6 +2611,40 @@ 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.

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)
```

> **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): 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 (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
// 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).
Expand Down
61 changes: 59 additions & 2 deletions tests/integration/bridge_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -90,7 +109,45 @@ 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


// A valid 0x-prefixed address used for cases that test amount validation.
validRecipient := "0x9160BBD07295b77BB168FF6295D66C74E575B5BE"

// Transfer Empty Bridge
_, err = tnClient.Transfer(ctx, "", validRecipient, "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 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", validRecipient, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "amount is required")

// Transfer Invalid Amount
_, 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: "",
Expand Down
Loading