Registrar la Billetera y Crear Transacciones con el SDK de Go
En la sección anterior, se creó y guardó un UL_Wallet en disco.
En esta sección:
- Se carga esa billetera desde un archivo
.ukey. - Se abre una sesión de transaction contra un node de ULedger.
- Se usa esa sesión para:
- enviar la primera transaction registrando la billetera en la cadena con
TX_CREATE_WALLET, - y luego enviar transacciones
DATAregulares con la misma billetera.
- enviar la primera transaction registrando la billetera en la cadena con
Conceptualmente:
- Registrar billetera = crear la identidad en la cadena (
TX_CREATE_WALLET). - Transacciones DATA = usar esa identidad para escribir datos en la cadena (
TX_DATA).
Reutilizaremos los mismos helpers (carga de configuración, carga de billetera, creación de sesión) para ambos.
1. Prerrequisitos
Antes de comenzar, se debe contar con:
- Un node de ULedger en ejecución (los ejemplos usan
https://your-node-urlcomo marcador de posición, por lo que se debe reemplazar por la URL de la red que se use). - Al menos un archivo de billetera creado con la guía Crear una Billetera con el Go SDK
(por ejemplo,./wallets/my_wallet.ukey). - Un pequeño archivo de configuración que liste los endpoints de node que se desea usar.
En este ejemplo asumiremos una pequeña red de desarrollo con cuatro nodes en total:
- 3 nodes de consejo – nodes que participan en el consensus y votan.
- 1 node adicional – p. ej. un node seguidor/observador.
En el modelo de consejo de ULedger normalmente se necesitan al menos 3 miembros del consejo en línea para alcanzar un voto válido (quórum). Por eso un consejo de 3 nodes es la configuración más pequeña que puede finalizar bloques.
Para simplificar, nuestro config.json apuntará solo a los tres nodes del consejo, ya que son los que enviaremos transacciones:
{
"nodeEndpoints": [
"https://your-node-url",
"https://your-node-2-url",
"https://your-node-3-url"
],
"keyType": 0,
"verbose": true
}
keyTypecorresponde al tipo de clave criptográfica;0generalmente se mapea asecp256k1en este SDK.
Se recomienda colocar este config.json en algún lugar conveniente (por ejemplo, la raíz del proyecto) y contar con un archivo de billetera, p. ej.: ./wallets/my_wallet.ukey.
2. Cargar configuración y billetera
Comencemos cargando:
- la configuración JSON en un
TestSuiteConfig, y - el archivo de billetera en un
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.FromJsonreconstruye el par de claves (clave pública/privada), la dirección,Parent,EnabledyAuthGroupsdesde el JSON, de modo que la billetera está lista para firmar y participar en verificaciones de permisos.
3. Crear una sesión de transaction
Ahora crearemos un helper que:
- Selecciona un endpoint de node de la configuración.
- Crea una
UL_TransactionSessionpara ese node y billetera.
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
}
Lo que hace NewUL_TransactionSession:
- Llama al endpoint
/healthdel node y lee metadatos básicos. - Llama a
/blockchainspara saber qué cadenas existen en este node. - Almacena el ID del node como el suggestor.
- Mantiene la billetera disponible para firmar.
No es necesario hacer esas llamadas HTTP de forma manual, esto se maneja dentro del SDK.
4. Registrar la billetera (TX_CREATE_WALLET)
Una transaction de registro de billetera es la primera transaction: crea una identidad en la cadena a la que otra lógica puede hacer referencia posteriormente.
En la cadena, la billetera se representa mediante un payload como:
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"`
}
Podemos envolver esto en un helper que construya el payload, cree el ULTransactionInput y llame a 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
}
Lo que significa cada campo en CreateWalletPayload:
PublicKey– la clave pública en hexadecimal de la billetera que estamos registrando.Parent– identificador opcional del propietario/creador. En muchas configuraciones esta es la dirección (o nombre) de una billetera admin/raíz existente.KeyType– qué tipo de clave se usa (p. ej.crypto.KeyTypeSecp256k1).AuthGroups– los grupos de permisos a los que pertenece esta billetera (de la sección anterior).
Este payload se almacena como el Payload de la transaction y es interpretado por la lógica de billeteras de la cadena.
Cómo funciona GenerateTransaction (una vez, para ambos flujos)
La primera vez que llamamos a GenerateTransaction es para TX_CREATE_WALLET, pero el mismo mecanismo se usa para DATA y otros tipos:
-
Completa campos adicionales en el input:
Suggestor(ID del node),SenderTimestamp,KeyType,PayloadRoot.
-
Para
TX_CREATE_WALLET, calcula un compromiso no vinculado (raíz Merkle) solo sobre el payload. -
Firma ese compromiso con la clave privada de la billetera.
-
Envía:
POST /blockchains/{blockchainId}/transactions -
Parsea la respuesta del node en una
ULTransactioncon:TransactionIdStatusOutputBlockHeightVectorClock, etc.
Para otros tipos de payload (como TX_DATA) el compromiso incluye más campos (remitente, payload, etc.), pero el flujo general es el mismo.
5. Enviar transacciones DATA
:::info Enviar los enteros grandes como strings
Los payloads JSON se codifican como doubles IEEE-754. Los enteros mayores que 2^53 − 1
(9007199254740991) se almacenan redondeados y la transaction se completa con éxito, por lo que un ID de pedido de
19 dígitos o un timestamp en nanosegundos se leen de vuelta como un número distinto.
// rounded on chain, becomes 1234567890123456800
payload, _ := json.Marshal(map[string]any{"orderId": 1234567890123456789})
// safe, stored exactly as sent
payload, _ := json.Marshal(map[string]any{"orderId": "1234567890123456789"})
Se deben enviar los enteros grandes como strings. Los valores iguales o menores que 2^53 − 1 no se ven afectados. :::
Una vez que la billetera está registrada (normalmente una operación por única vez por billetera por cadena), se puede usar para enviar transacciones DATA normales.
Definiremos un pequeño helper similar a 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
}
Esto usa el mismo flujo de GenerateTransaction que se vio anteriormente, solo con un PayloadType y cuerpo de payload diferentes.
La respuesta del node incluye:
TransactionIdStatus(p. ej."SUBMITTED","ACCEPTED","REJECTED")Output(p. ej."SUCCESS","REJECTED_BY_UNAUTHORIZED")- más metadatos como
BlockHeightyVectorClock.
:::warning Una transaction enviada no es una transaction aceptada
El status y el output de la respuesta de envío describen la transaction tal como se entregó al node.
No la confirman. Para confirmarla, se debe volver a leer la transaction desde el node hasta que tenga un
blockHeight y su output sea SUCCESS, o consultar GET {node}/blockchains/{blockchainId}/transactions/{transactionId}/status
y esperar state: "committed". Una transaction que la red rechaza se descarta y nunca aparece, por lo que, si no se ha
confirmado dentro de la ventana de espera, debe tratarse como fallida y no como pendiente.
:::
6. Uniendo todo
Aquí hay un programa mínimo que:
- Carga la configuración y la billetera.
- Crea una sesión de transaction.
- Registra la billetera con
TX_CREATE_WALLET(primera transaction). - Envía algunas transacciones
DATAcon la misma billetera.
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,
)
}
}
Se puede ejecutar con algo como:
go run ./cmd/send_transactions
(o cualquier ruta donde se coloque main.go en el proyecto).