diff --git a/commands/transactions.go b/commands/transactions.go index f40fa3c..c6ca674 100644 --- a/commands/transactions.go +++ b/commands/transactions.go @@ -25,3 +25,19 @@ type UpdateTransactionMetadata struct { ID string `json:"-"` // Unique identifier of the transaction to be updated. Metadata querybuilders.Metadata `json:"metadata"` // New metadata to associate with the transaction. } + +// Recipients represents a single recipient in a transaction. +// It includes details about the recipient address, the amount to send, +// and an optional OP_RETURN script for including additional data in the transaction. +type Recipients struct { + OpReturn *response.OpReturn `json:"op_return"` // Optional OP_RETURN script for attaching data to the transaction. + Satoshis uint64 `json:"satoshis"` // Amount to send to the recipient, in satoshis. + To string `json:"to"` // Paymails address of the recipient. +} + +// SendToRecipients holds the arguments required to send a transaction to multiple recipients. +// This includes the list of recipients with their details and optional metadata for the transaction. +type SendToRecipients struct { + Recipients []*Recipients `json:"recipients"` // List of recipients for the transaction. + Metadata querybuilders.Metadata `json:"metadata"` // Metadata associated with the transaction. +} diff --git a/examples/Taskfile.yml b/examples/Taskfile.yml index c74fd13..7ca3a84 100644 --- a/examples/Taskfile.yml +++ b/examples/Taskfile.yml @@ -1,4 +1,4 @@ -version: '3' +version: "3" tasks: default: @@ -98,6 +98,12 @@ tasks: cmds: - go run ./update_transaction_metadata/update_transaction_metadata.go + create-transaction: + desc: "Send OP return." + silent: true + cmds: + - go run ./send_op_return/send_op_return.go + fetch-user-utxos: desc: "Fetch user UTXOs page." silent: true diff --git a/examples/draft_transaction/draft_transaction.go b/examples/draft_transaction/draft_transaction.go deleted file mode 100644 index 799cc98..0000000 --- a/examples/draft_transaction/draft_transaction.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "context" - "log" - - wallet "github.com/bitcoin-sv/spv-wallet-go-client" - "github.com/bitcoin-sv/spv-wallet-go-client/commands" - "github.com/bitcoin-sv/spv-wallet-go-client/examples" - "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" - "github.com/bitcoin-sv/spv-wallet/models/response" -) - -func main() { - usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) - if err != nil { - log.Fatal(err) - } - - transaction, err := usersAPI.DraftTransaction(context.Background(), &commands.DraftTransaction{ - Config: response.TransactionConfig{ - Outputs: []*response.TransactionOutput{ - { - To: "receiver@example.com", - Satoshis: 1, - }, - }, - }, - Metadata: map[string]any{"key": "value"}, - }) - if err != nil { - log.Fatal(err) - } - - exampleutil.Print("[HTTP POST] Draft transaction - api/v1/transactions", transaction) -} diff --git a/examples/send_op_return/send_op_return.go b/examples/send_op_return/send_op_return.go new file mode 100644 index 0000000..8521dc3 --- /dev/null +++ b/examples/send_op_return/send_op_return.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "fmt" + "log" + + wallet "github.com/bitcoin-sv/spv-wallet-go-client" + "github.com/bitcoin-sv/spv-wallet-go-client/commands" + "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/examples/exampleutil" + "github.com/bitcoin-sv/spv-wallet/models/response" +) + +func main() { + usersAPI, err := wallet.NewUserAPIWithXPriv(exampleutil.ExampleConfig, examples.XPriv) + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + + metadata := map[string]any{} + + opReturn := response.OpReturn{StringParts: []string{"hello", "world"}} + draftTransactionCmd := commands.DraftTransaction{ + Config: response.TransactionConfig{ + Outputs: []*response.TransactionOutput{ + { + OpReturn: &opReturn, + }, + }, + }, + Metadata: metadata, + } + + draftTransaction, err := usersAPI.DraftTransaction(ctx, &draftTransactionCmd) + if err != nil { + log.Fatal(err) + } + exampleutil.Print("DraftTransaction response: ", draftTransaction) + + finalized, err := usersAPI.FinalizeTransaction(draftTransaction) + if err != nil { + log.Fatal(err) + } + fmt.Println("Finalized transaction hex : ", finalized) + + recordTransactionCmd := commands.RecordTransaction{ + Hex: finalized, + Metadata: metadata, + ReferenceID: draftTransaction.ID, + } + transaction, err := usersAPI.RecordTransaction(ctx, &recordTransactionCmd) + if err != nil { + log.Fatal(err) + } + fmt.Println("Transaction with OP_RETURN: ", transaction) + + transactionG, err := usersAPI.Transaction(context.Background(), transaction.ID) + if err != nil { + log.Fatal(err) + } + fmt.Println("Transaction: ", transactionG) +} diff --git a/internal/api/v1/user/transactions/transactions_api.go b/internal/api/v1/user/transactions/transactions_api.go index c669b7a..c35394f 100644 --- a/internal/api/v1/user/transactions/transactions_api.go +++ b/internal/api/v1/user/transactions/transactions_api.go @@ -5,9 +5,11 @@ import ( "fmt" "net/url" + bip32 "github.com/bitcoin-sv/go-sdk/compat/bip32" "github.com/bitcoin-sv/spv-wallet-go-client/commands" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/errutil" "github.com/bitcoin-sv/spv-wallet-go-client/internal/api/v1/querybuilders" + "github.com/bitcoin-sv/spv-wallet-go-client/internal/auth" "github.com/bitcoin-sv/spv-wallet-go-client/queries" "github.com/bitcoin-sv/spv-wallet/models/response" "github.com/go-resty/resty/v2" @@ -20,6 +22,55 @@ type API struct { httpClient *resty.Client } +func (a *API) FinalizeTransaction(draft *response.DraftTransaction, xPriv *bip32.ExtendedKey) (string, error) { + res, err := auth.GetSignedHex(draft, xPriv) + if err != nil { + return "", err + } + + return res, nil +} + +func (a *API) DraftToRecipients(ctx context.Context, r *commands.SendToRecipients) (*response.DraftTransaction, error) { + outputs := make([]*response.TransactionOutput, 0) + + for _, recipient := range r.Recipients { + outputs = append(outputs, &response.TransactionOutput{ + To: recipient.To, + Satoshis: recipient.Satoshis, + OpReturn: recipient.OpReturn, + }) + } + + draftTransactionCmd := &commands.DraftTransaction{ + Config: response.TransactionConfig{ + Outputs: outputs, + }, + Metadata: r.Metadata, + } + + return a.DraftTransaction(ctx, draftTransactionCmd) +} + +func (a *API) SendToRecipients(ctx context.Context, r *commands.SendToRecipients, xPriv *bip32.ExtendedKey) (*response.Transaction, error) { + draft, err := a.DraftToRecipients(ctx, r) + if err != nil { + return nil, err + } + + var hex string + if hex, err = a.FinalizeTransaction(draft, xPriv); err != nil { + return nil, err + } + + recordTransactionCmd := &commands.RecordTransaction{ + Metadata: r.Metadata, + Hex: hex, + ReferenceID: draft.ID, + } + return a.RecordTransaction(ctx, recordTransactionCmd) +} + func (a *API) DraftTransaction(ctx context.Context, r *commands.DraftTransaction) (*response.DraftTransaction, error) { var result response.DraftTransaction diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 1d35318..0af3c4b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -16,9 +16,10 @@ import ( "github.com/bitcoin-sv/go-sdk/transaction/template/p2pkh" "github.com/bitcoin-sv/spv-wallet-go-client/internal/cryptoutil" "github.com/bitcoin-sv/spv-wallet/models" + "github.com/bitcoin-sv/spv-wallet/models/response" ) -func GetSignedHex(dt *models.DraftTransaction, xPriv *bip32.ExtendedKey) (string, error) { +func GetSignedHex(dt *response.DraftTransaction, xPriv *bip32.ExtendedKey) (string, error) { // Create transaction from hex tx, err := trx.NewTransactionFromHex(dt.Hex) // we need to reset the inputs as we are going to add them via tx.AddInputFrom (ts-sdk method) and then sign @@ -66,7 +67,7 @@ func setSignature(header *http.Header, xPriv *bip32.ExtendedKey, bodyString stri return nil } -func prepareLockingScript(dst *models.Destination) (*script.Script, error) { +func prepareLockingScript(dst *response.Destination) (*script.Script, error) { lockingScript, err := script.NewFromHex(dst.LockingScript) if err != nil { return nil, fmt.Errorf("failed to create locking script from hex for destination: %w", err) @@ -75,7 +76,7 @@ func prepareLockingScript(dst *models.Destination) (*script.Script, error) { return lockingScript, nil } -func prepareUnlockingScript(xPriv *bip32.ExtendedKey, dst *models.Destination) (*p2pkh.P2PKH, error) { +func prepareUnlockingScript(xPriv *bip32.ExtendedKey, dst *response.Destination) (*p2pkh.P2PKH, error) { key, err := getDerivedKeyForDestination(xPriv, dst) if err != nil { return nil, fmt.Errorf("failed to get derived key for destination: %w", err) @@ -84,7 +85,7 @@ func prepareUnlockingScript(xPriv *bip32.ExtendedKey, dst *models.Destination) ( return getUnlockingScript(key) } -func getDerivedKeyForDestination(xPriv *bip32.ExtendedKey, dst *models.Destination) (*ec.PrivateKey, error) { +func getDerivedKeyForDestination(xPriv *bip32.ExtendedKey, dst *response.Destination) (*ec.PrivateKey, error) { // Derive the child key (m/chain/num) derivedKey, err := bip32.GetHDKeyByPath(xPriv, dst.Chain, dst.Num) if err != nil { diff --git a/user_api.go b/user_api.go index a2a7a6a..6f1bda0 100644 --- a/user_api.go +++ b/user_api.go @@ -37,6 +37,7 @@ import ( // UserAPI methods may return wrapped errors, including models.SPVError or // ErrUnrecognizedAPIResponse, depending on the behavior of the SPV Wallet API. type UserAPI struct { + xPriv *bip32.ExtendedKey xpubAPI *users.XPubAPI accessKeyAPI *users.AccessKeyAPI configsAPI *configs.API @@ -226,6 +227,34 @@ func (u *UserAPI) Transaction(ctx context.Context, ID string) (*response.Transac return res, nil } +// FinalizeTransaction finalizes a draft transaction and returns its signed hex representation. +// It uses the draft transaction details to construct, enrich, and sign the transaction +// through the `auth.GetSignedHex` utility function. +// The response is the signed transaction in hex format. +// Returns an error if the transaction cannot be finalized. +func (u *UserAPI) FinalizeTransaction(draft *response.DraftTransaction) (string, error) { + res, err := u.transactionsAPI.FinalizeTransaction(draft, u.xPriv) + if err != nil { + return "", fmt.Errorf("couldn't finalize transaction with ID: %s, %w", draft.ID, err) + } + + return res, nil +} + +// SendToRecipients creates, finalizes, and broadcasts a transaction to multiple recipients. +// This method handles the complete process of drafting, finalizing, and recording the transaction +// using the recipient details provided in the command. +// The response is unmarshalled into a *response.Transaction struct. +// Returns an error if the transaction fails at any step, such as drafting, finalization or recording. +func (u *UserAPI) SendToRecipients(ctx context.Context, cmd *commands.SendToRecipients) (*response.Transaction, error) { + res, err := u.transactionsAPI.SendToRecipients(ctx, cmd, u.xPriv) + if err != nil { + return nil, transactions.HTTPErrorFormatter("send to recipients", err).FormatPostErr() + } + + return res, nil +} + // XPub retrieves the full xpub information for the current user via the users API. // The response is unmarshaled into a *response.Xpub. // Returns an error if the request fails or the response cannot be decoded. @@ -395,7 +424,7 @@ func NewUserAPIWithXPub(cfg config.Config, xPub string) (*UserAPI, error) { return nil, fmt.Errorf("failed to intialized xPub authenticator: %w", err) } - return initUserAPI(cfg, authenticator) + return initUserAPI(cfg, nil, authenticator) } // NewUserAPIWithXPriv initializes a new UserAPI instance using an extended private key (xPriv). @@ -414,7 +443,7 @@ func NewUserAPIWithXPriv(cfg config.Config, xPriv string) (*UserAPI, error) { return nil, fmt.Errorf("failed to intialized xPriv authenticator: %w", err) } - userAPI, err := initUserAPI(cfg, authenticator) + userAPI, err := initUserAPI(cfg, key, authenticator) if err != nil { return nil, fmt.Errorf("failed to create new client: %w", err) } @@ -439,14 +468,14 @@ func NewUserAPIWithAccessKey(cfg config.Config, accessKey string) (*UserAPI, err return nil, fmt.Errorf("failed to intialized access key authenticator: %w", err) } - return initUserAPI(cfg, authenticator) + return initUserAPI(cfg, nil, authenticator) } type authenticator interface { Authenticate(r *resty.Request) error } -func initUserAPI(cfg config.Config, auth authenticator) (*UserAPI, error) { +func initUserAPI(cfg config.Config, xPriv *bip32.ExtendedKey, auth authenticator) (*UserAPI, error) { url, err := url.Parse(cfg.Addr) if err != nil { return nil, fmt.Errorf("failed to parse addr to url.URL: %w", err) @@ -454,6 +483,7 @@ func initUserAPI(cfg config.Config, auth authenticator) (*UserAPI, error) { httpClient := restyutil.NewHTTPClient(cfg, auth) return &UserAPI{ + xPriv: xPriv, merkleRootsAPI: merkleroots.NewAPI(url, httpClient), configsAPI: configs.NewAPI(url, httpClient), transactionsAPI: transactions.NewAPI(url, httpClient),