Skip to main content

Register your Wallet and Create Transactions with the Go SDK

In the previous section, you created and saved a UL_Wallet to disk.

In this section, you’ll:

  1. Load that wallet from a .ukey file.
  2. Open a transaction session against a ULedger node.
  3. Use that session to:
    • send your first transaction by registering the wallet on-chain with TX_CREATE_WALLET,
    • and then send regular DATA transactions with the same wallet.

Conceptually:

  • Register wallet = create your identity on-chain (TX_CREATE_WALLET).
  • DATA transactions = use that identity to write data to the chain (TX_DATA).

We’ll reuse the same helpers (config loading, wallet loading, session creation) for both.


1. Prerequisites

Before you start, you should have:

  • A running ULedger node (or access to dev nodes like https://tn-w-1.uledger.net/).
  • At least one wallet file created with the Create a Wallet with the Go SDK guide
    (for example, ./wallets/my_wallet.ukey).
  • A small config file that lists the node endpoints you want to use.

In this example we’ll assume a small dev network with four nodes in total:

  • 3 council nodes – nodes that participate in consensus and vote.
  • 1 extra node – e.g. a follower/observer node.

In ULedger’s council model you typically need at least 3 council members online to reach a valid vote (quorum). That’s why a 3-node council is the smallest setup that can actually finalize blocks.

For simplicity, our config.json will point only at the three council nodes, since those are the ones we’ll send transactions to:

{
"nodeEndpoints": [
"https://tn-w-1.uledger.net/",
"https://my.node2.uledger.io",
"https://my.node3.uledger.io"
],
"keyType": 0,
"verbose": true
}

keyType corresponds to the crypto key type; 0 usually maps to secp256k1 in this SDK.

Place this config.json somewhere convenient (for example, the project root) and make sure you have a wallet file, e.g.: ./wallets/my_wallet.ukey.


2. Load configuration and wallet

Let’s start by loading:

  • your JSON config into a TestSuiteConfig, and
  • your wallet file into a UL_Wallet.
package main

import (
"encoding/json"
"fmt"
"os"

"github.com/ULedgerInc/go-sdk/pkg/test_suite"
"github.com/ULedgerInc/go-sdk/pkg/wallet"
)

func loadConfig(path string) (test_suite.TestSuiteConfig, error) {
var cfg test_suite.TestSuiteConfig

data, err := os.ReadFile(path)
if err != nil {
return cfg, fmt.Errorf("read config file: %w", err)
}

if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse config file: %w", err)
}

if len(cfg.NodeEndpoints) == 0 {
return cfg, fmt.Errorf("config must contain at least one node endpoint")
}

return cfg, nil
}

func loadWallet(path, passphrase string) (*wallet.UL_Wallet, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read wallet file: %w", err)
}

w, err := wallet.FromJson(string(data), passphrase)
if err != nil {
return nil, fmt.Errorf("parse wallet JSON: %w", err)
}

return w, nil
}

wallet.FromJson reconstructs the keypair (public/private key), address, Parent, Enabled and AuthGroups from the JSON, so the wallet is ready to sign and participate in permission checks.


3. Create a transaction session

Now we’ll create a helper that:

  1. Picks a node endpoint from the config.
  2. Creates a UL_TransactionSession for that node and wallet.
import (
"fmt"
"math/rand/v2"

"github.com/ULedgerInc/go-sdk/pkg/test_suite"
"github.com/ULedgerInc/go-sdk/pkg/transaction"
"github.com/ULedgerInc/go-sdk/pkg/wallet"
)

func newRandomSession(cfg test_suite.TestSuiteConfig, w *wallet.UL_Wallet) (transaction.UL_TransactionSession, error) {
// Pick a random node from the config
idx := rand.IntN(len(cfg.NodeEndpoints))
nodeEndpoint := cfg.NodeEndpoints[idx]

// NewUL_TransactionSession internally calls /health and /blockchains
// and stores the nodeId as the "suggestor"
session, err := transaction.NewUL_TransactionSession(nodeEndpoint, *w)
if err != nil {
return transaction.UL_TransactionSession{}, fmt.Errorf("create transaction session: %w", err)
}

fmt.Printf("Using node %s\n", nodeEndpoint)
return session, nil
}

What NewUL_TransactionSession does for you:

  • Calls the node’s /health endpoint and reads basic metadata.
  • Calls /blockchains to know what chains exist on this node.
  • Stores the node ID as the suggestor.
  • Keeps your wallet handy for signing.

You don’t need to make those HTTP calls yourself—this is handled inside the SDK.


4. Register your wallet (TX_CREATE_WALLET)

A register wallet transaction is your first transaction: it creates an on-chain identity that other logic can refer to later.

On-chain, the wallet is represented by a payload like:

import (
"encoding/json"

"github.com/ULedgerInc/go-sdk/pkg/crypto"
"github.com/ULedgerInc/go-sdk/pkg/transaction"
"github.com/ULedgerInc/go-sdk/pkg/wallet"
)

type CreateWalletPayload struct {
PublicKey string `json:"publicKey"`
Parent string `json:"parent"`
KeyType crypto.KeyType `json:"keyType"`
AuthGroups map[string]wallet.UL_AuthPermission `json:"authGroups,omitempty"`
}

We can wrap this in a helper that builds the payload, constructs the ULTransactionInput and calls GenerateTransaction:

func registerWallet(
session transaction.UL_TransactionSession,
w *wallet.UL_Wallet,
blockchainId string,
) (*transaction.ULTransaction, error) {
// 1) Build the create-wallet payload
payloadBody := CreateWalletPayload{
PublicKey: w.GetKey().GetPublicKeyHex(false),
Parent: w.Parent,
KeyType: w.GetKey().GetType(),
AuthGroups: w.AuthGroups,
}

payloadBytes, err := json.Marshal(payloadBody)
if err != nil {
return nil, fmt.Errorf("marshal create-wallet payload: %w", err)
}

// 2) Build the ULTransactionInput for TX_CREATE_WALLET
input := transaction.ULTransactionInput{
BlockchainId: blockchainId,
To: w.Address, // wallet being created
From: w.Parent, // author / creator (see note below)
Payload: string(payloadBytes),
PayloadType: transaction.TX_CREATE_WALLET.String(),
}

// 3) Generate and submit the transaction
tx, err := session.GenerateTransaction(input)
if err != nil {
return nil, fmt.Errorf("generate TX_CREATE_WALLET: %w", err)
}

if tx.TransactionId == "" {
return nil, fmt.Errorf("TX_CREATE_WALLET returned empty transaction ID")
}

return &tx, nil
}

What each field in CreateWalletPayload means:

  • PublicKey – the hex public key of the wallet we’re registering.
  • Parent – optional owner/creator identifier. In many setups this is the address (or name) of an existing admin/root wallet.
  • KeyType – which key type is used (e.g. crypto.KeyTypeSecp256k1).
  • AuthGroups – the permission groups this wallet belongs to (from the previous section).

This payload is stored as the Payload of the transaction and interpreted by your chain’s wallet logic.

How GenerateTransaction works (once, for both flows)

The first time we call GenerateTransaction is for TX_CREATE_WALLET, but the same mechanism is used for DATA and other types:

  1. Fills in extra fields on the input:

    • Suggestor (node ID),
    • SenderTimestamp,
    • KeyType,
    • PayloadRoot.
  2. For TX_CREATE_WALLET, computes an unbound commitment (Merkle root) over the payload only.

  3. Signs that commitment with the wallet’s private key.

  4. Sends:

    POST /blockchains/{blockchainId}/transactions
  5. Parses the node’s response into a ULTransaction with:

    • TransactionId
    • Status
    • Output
    • BlockHeight
    • VectorClock, etc.

For other payload types (like TX_DATA) the commitment includes more fields (sender, payload, etc.), but the overall flow is the same.

5. Send DATA transactions

Once the wallet is registered (usually a one-time operation per wallet per chain), you can use it to send normal DATA transactions.

We’ll define a small helper similar to registerWallet:

func sendDataTransaction(
session transaction.UL_TransactionSession,
w *wallet.UL_Wallet,
blockchainId string,
payload string,
) (*transaction.ULTransaction, error) {
input := transaction.ULTransactionInput{
Payload: payload,
From: w.Address,
To: w.Address, // self-transfer (good for testing)
BlockchainId: blockchainId, // the chain you want to hit
PayloadType: transaction.TX_DATA.String(),
}

tx, err := session.GenerateTransaction(input)
if err != nil {
return nil, fmt.Errorf("generate DATA transaction: %w", err)
}

if tx.TransactionId == "" {
return nil, fmt.Errorf("generated transaction has empty transaction ID")
}

return &tx, nil
}

This uses the same GenerateTransaction flow you saw above, just with a different PayloadType and payload body.

The node’s response will give you:

  • TransactionId
  • Status (e.g. "SUBMITTED", "ACCEPTED", "REJECTED")
  • Output (e.g. "SUCCESS", "REJECTED_BY_UNAUTHORIZED")
  • plus metadata like BlockHeight and VectorClock.

6. Putting it all together

Here is a minimal program that:

  1. Loads config and wallet.
  2. Creates a transaction session.
  3. Registers the wallet with TX_CREATE_WALLET (first transaction).
  4. Sends a few DATA transactions with the same wallet.
package main

import (
"encoding/json"
"fmt"
"math/rand/v2"
"os"

"github.com/ULedgerInc/go-sdk/pkg/crypto"
"github.com/ULedgerInc/go-sdk/pkg/test_suite"
"github.com/ULedgerInc/go-sdk/pkg/transaction"
"github.com/ULedgerInc/go-sdk/pkg/wallet"
)

type CreateWalletPayload struct {
PublicKey string `json:"publicKey"`
Parent string `json:"parent"`
KeyType crypto.KeyType `json:"keyType"`
AuthGroups map[string]wallet.UL_AuthPermission `json:"authGroups,omitempty"`
}

func loadConfig(path string) (test_suite.TestSuiteConfig, error) {
var cfg test_suite.TestSuiteConfig

data, err := os.ReadFile(path)
if err != nil {
return cfg, fmt.Errorf("read config file: %w", err)
}

if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse config file: %w", err)
}

if len(cfg.NodeEndpoints) == 0 {
return cfg, fmt.Errorf("config must contain at least one node endpoint")
}

return cfg, nil
}

func loadWallet(path, passphrase string) (*wallet.UL_Wallet, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read wallet file: %w", err)
}

w, err := wallet.FromJson(string(data), passphrase)
if err != nil {
return nil, fmt.Errorf("parse wallet JSON: %w", err)
}

return w, nil
}

func newRandomSession(cfg test_suite.TestSuiteConfig, w *wallet.UL_Wallet) (transaction.UL_TransactionSession, error) {
idx := rand.IntN(len(cfg.NodeEndpoints))
nodeEndpoint := cfg.NodeEndpoints[idx]

session, err := transaction.NewUL_TransactionSession(nodeEndpoint, *w)
if err != nil {
return transaction.UL_TransactionSession{}, fmt.Errorf("create transaction session: %w", err)
}

fmt.Printf("Using node %s\n", nodeEndpoint)
return session, nil
}

func registerWallet(
session transaction.UL_TransactionSession,
w *wallet.UL_Wallet,
blockchainId string,
) (*transaction.ULTransaction, error) {
payloadBody := CreateWalletPayload{
PublicKey: w.GetKey().GetPublicKeyHex(false),
Parent: w.Parent,
KeyType: w.GetKey().GetType(),
AuthGroups: w.AuthGroups,
}

payloadBytes, err := json.Marshal(payloadBody)
if err != nil {
return nil, fmt.Errorf("marshal create-wallet payload: %w", err)
}

input := transaction.ULTransactionInput{
BlockchainId: blockchainId,
To: w.Address, // wallet being created
From: w.Parent, // creator / parent
Payload: string(payloadBytes),
PayloadType: transaction.TX_CREATE_WALLET.String(),
}

tx, err := session.GenerateTransaction(input)
if err != nil {
return nil, fmt.Errorf("generate TX_CREATE_WALLET: %w", err)
}

if tx.TransactionId == "" {
return nil, fmt.Errorf("TX_CREATE_WALLET returned empty transaction ID")
}

return &tx, nil
}

func sendDataTransaction(
session transaction.UL_TransactionSession,
w *wallet.UL_Wallet,
blockchainId string,
payload string,
) (*transaction.ULTransaction, error) {
input := transaction.ULTransactionInput{
Payload: payload,
From: w.Address,
To: w.Address,
BlockchainId: blockchainId,
PayloadType: transaction.TX_DATA.String(),
}

tx, err := session.GenerateTransaction(input)
if err != nil {
return nil, fmt.Errorf("generate DATA transaction: %w", err)
}

if tx.TransactionId == "" {
return nil, fmt.Errorf("generated transaction has empty transaction ID")
}

return &tx, nil
}

func main() {
// Adjust these paths/IDs for your environment
const (
configPath = "config.json"
walletPath = "./wallets/my_wallet.ukey"
passphrase = "" // or your mnemonic passphrase if you used one
blockchainId = "your-blockchain-id-here"
txCount = 3
)

cfg, err := loadConfig(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Config error: %v\n", err)
os.Exit(1)
}

w, err := loadWallet(walletPath, passphrase)
if err != nil {
fmt.Fprintf(os.Stderr, "Wallet error: %v\n", err)
os.Exit(1)
}

fmt.Printf("Loaded wallet: %s (parent=%q)\n", w.Address, w.Parent)

session, err := newRandomSession(cfg, w)
if err != nil {
fmt.Fprintf(os.Stderr, "Session error: %v\n", err)
os.Exit(1)
}

// First: register the wallet on-chain (usually one-time per wallet/chain).
regTx, err := registerWallet(session, w, blockchainId)
if err != nil {
fmt.Fprintf(os.Stderr, "Register wallet error: %v\n", err)
os.Exit(1)
}

fmt.Printf("Registered wallet %s via TX %s (status=%s, output=%s)\n",
w.Address, regTx.TransactionId, regTx.Status, regTx.Output)

// Then: send some DATA transactions using the same wallet.
for i := 0; i < txCount; i++ {
payload := fmt.Sprintf("test payload from %s, tx #%d", w.Address, i+1)

tx, err := sendDataTransaction(session, w, blockchainId, payload)
if err != nil {
fmt.Fprintf(os.Stderr, "DATA transaction error: %v\n", err)
os.Exit(1)
}

fmt.Printf(
"Created DATA transaction %s (status=%s, payloadRoot=%s)\n",
tx.TransactionId,
tx.Status,
tx.PayloadRoot,
)
}
}

You can run this with something like:

go run ./cmd/send_transactions

(or whatever path you place main.go under in your project).