Skip to content

Commit cfd9272

Browse files
committed
cmd/tx: Implement Safe transaction batch output format
1 parent fe04d6c commit cfd9272

6 files changed

Lines changed: 161 additions & 14 deletions

File tree

cmd/account/deposit.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ var depositCmd = &cobra.Command{
7676
cobra.CheckErr(err)
7777

7878
if txCfg.Export {
79-
common.ExportTransaction(sigTx)
79+
common.ExportTransaction(npa.Network, sigTx)
8080
return
8181
}
8282

cmd/account/withdraw.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ var withdrawCmd = &cobra.Command{
9393
cobra.CheckErr(err)
9494

9595
if txCfg.Export {
96-
common.ExportTransaction(sigTx)
96+
common.ExportTransaction(npa.Network, sigTx)
9797
return
9898
}
9999

cmd/common/safe.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package common
2+
3+
import (
4+
"encoding/hex"
5+
"encoding/json"
6+
"fmt"
7+
"strconv"
8+
"strings"
9+
"time"
10+
11+
ethAbi "github.com/ethereum/go-ethereum/accounts/abi"
12+
"github.com/oasisprotocol/oasis-core/go/common/cbor"
13+
"github.com/oasisprotocol/oasis-core/go/common/quantity"
14+
15+
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/config"
16+
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/types"
17+
)
18+
19+
// subcallAddress is the address of the "Subcall" precompile that dispatches
20+
// ABI-encoded (method, CBOR body) calldata as an SDK runtime call. See
21+
// https://github.com/oasisprotocol/oasis-sdk/blob/main/runtime-sdk/modules/evm/src/precompile/subcall.rs.
22+
const subcallAddress = "0x0100000000000000000000000000000000000103"
23+
24+
// safeTxBuilderTransaction is a single call within a Safe Transaction Builder batch.
25+
type safeTxBuilderTransaction struct {
26+
To string `json:"to"`
27+
Value string `json:"value"`
28+
Data string `json:"data"`
29+
ContractMethod interface{} `json:"contractMethod"`
30+
ContractInputsValues interface{} `json:"contractInputsValues"`
31+
}
32+
33+
// safeTxBuilderBatch is the file format understood by Safe's Transaction Builder app.
34+
// See https://help.safe.global/en/articles/40841-transaction-builder.
35+
type safeTxBuilderBatch struct {
36+
Version string `json:"version"`
37+
ChainID string `json:"chainId"`
38+
CreatedAt int64 `json:"createdAt"`
39+
Meta struct {
40+
Name string `json:"name"`
41+
Description string `json:"description"`
42+
} `json:"meta"`
43+
Transactions []safeTxBuilderTransaction `json:"transactions"`
44+
}
45+
46+
// exportSafe wraps an unsigned Oasis transaction into a calldata for the
47+
// Subcall precompile, and packages it for Safe.
48+
func exportSafe(network *config.Network, sigTx interface{}) ([]byte, error) {
49+
tx, ok := sigTx.(*types.Transaction)
50+
if !ok {
51+
return nil, fmt.Errorf("--format safe is only supported for unsigned ParaTime transactions")
52+
}
53+
if network == nil {
54+
return nil, fmt.Errorf("--format safe requires a network to be configured")
55+
}
56+
var chainID uint64 = 23294 // Use Safe on Sapphire Mainnet by default.
57+
if network.ChainContext != config.DefaultNetworks.All["mainnet"].ChainContext {
58+
chainID = 23295 // Sapphire Testnet
59+
}
60+
stringType, err := ethAbi.NewType("string", "", nil)
61+
if err != nil {
62+
return nil, err
63+
}
64+
bytesType, err := ethAbi.NewType("bytes", "", nil)
65+
if err != nil {
66+
return nil, err
67+
}
68+
calldata, err := ethAbi.Arguments{{Type: stringType}, {Type: bytesType}}.Pack(string(tx.Call.Method), []byte(tx.Call.Body))
69+
if err != nil {
70+
return nil, fmt.Errorf("failed to ABI-encode subcall: %w", err)
71+
}
72+
73+
batch := safeTxBuilderBatch{
74+
Version: "1.0",
75+
ChainID: strconv.FormatUint(chainID, 10),
76+
CreatedAt: time.Now().UnixMilli(),
77+
Transactions: []safeTxBuilderTransaction{{
78+
To: subcallAddress,
79+
Value: "0",
80+
Data: "0x" + hex.EncodeToString(calldata),
81+
}},
82+
}
83+
batch.Meta.Name = string(tx.Call.Method)
84+
batch.Meta.Description = fmt.Sprintf("Oasis CLI-generated Subcall wrapping '%s' for proposal via Safe.", tx.Call.Method)
85+
86+
return json.MarshalIndent(batch, "", " ")
87+
}
88+
89+
// DecodeSafe attempts to decode raw bytes as a Safe Transaction Builder batch
90+
// and unwraps Subcall precompile calldata back into an unsigned ParaTime
91+
// transaction.
92+
//
93+
// The resulting transaction only has Call.Method and Call.Body populated, since
94+
// the Safe batch does not carry sender, nonce, or fee information.
95+
func DecodeSafe(raw []byte) (*types.Transaction, error) {
96+
var batch safeTxBuilderBatch
97+
if err := json.Unmarshal(raw, &batch); err != nil {
98+
return nil, err
99+
}
100+
if batch.Version == "" || len(batch.Transactions) != 1 {
101+
return nil, fmt.Errorf("file not a Safe Transaction Builder batch with a single transaction")
102+
}
103+
104+
txn := batch.Transactions[0]
105+
if !strings.EqualFold(txn.To, subcallAddress) {
106+
return nil, fmt.Errorf("transaction does not call the Subcall precompile at %s", subcallAddress)
107+
}
108+
109+
calldata, err := hex.DecodeString(strings.TrimPrefix(txn.Data, "0x"))
110+
if err != nil {
111+
return nil, fmt.Errorf("malformed calldata: %w", err)
112+
}
113+
114+
stringTy, err := ethAbi.NewType("string", "", nil)
115+
if err != nil {
116+
return nil, err
117+
}
118+
bytesTy, err := ethAbi.NewType("bytes", "", nil)
119+
if err != nil {
120+
return nil, err
121+
}
122+
vals, err := (ethAbi.Arguments{{Type: stringTy}, {Type: bytesTy}}).Unpack(calldata)
123+
if err != nil {
124+
return nil, fmt.Errorf("failed to ABI-decode subcall data: %w", err)
125+
}
126+
127+
tx := &types.Transaction{Versioned: cbor.NewVersioned(types.LatestTransactionVersion)}
128+
tx.Call.Method = types.MethodName(vals[0].(string))
129+
tx.Call.Body = vals[1].([]byte)
130+
tx.AuthInfo.Fee.Amount = types.NewBaseUnits(*quantity.NewFromUint64(0), types.NativeDenomination)
131+
return tx, nil
132+
}

cmd/common/transaction.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const (
5050

5151
formatJSON = "json"
5252
formatCBOR = "cbor"
53+
formatSafe = "safe"
5354
)
5455

5556
var (
@@ -420,7 +421,7 @@ func PrintTransactionBeforeSigning(npa *NPASelection, tx interface{}, txDetails
420421
}
421422

422423
// ExportTransaction exports a (signed) transaction based on configuration.
423-
func ExportTransaction(sigTx interface{}) {
424+
func ExportTransaction(network *config.Network, sigTx interface{}) {
424425
// Determine output destination.
425426
var err error
426427
outputFile := os.Stdout
@@ -440,6 +441,9 @@ func ExportTransaction(sigTx interface{}) {
440441
cobra.CheckErr(err)
441442
case formatCBOR:
442443
data = cbor.Marshal(sigTx)
444+
case formatSafe:
445+
data, err = exportSafe(network, sigTx)
446+
cobra.CheckErr(err)
443447
default:
444448
cobra.CheckErr(fmt.Errorf("unknown transaction format: %s", txFormat))
445449
}
@@ -463,7 +467,7 @@ func BroadcastOrExportTransaction(
463467
result interface{},
464468
) bool {
465469
if shouldExportTransaction() {
466-
ExportTransaction(tx)
470+
ExportTransaction(npa.Network, tx)
467471
return false
468472
}
469473

@@ -627,7 +631,7 @@ func init() {
627631
RuntimeTxFlags.BoolVar(&txEncrypted, "encrypted", false, "encrypt transaction call data (requires online mode)")
628632
RuntimeTxFlags.AddFlagSet(AnswerYesFlag)
629633
RuntimeTxFlags.BoolVar(&txUnsigned, "unsigned", false, "do not sign transaction")
630-
RuntimeTxFlags.StringVar(&txFormat, "format", "json", "transaction output format (for offline/unsigned modes) [json, cbor]")
634+
RuntimeTxFlags.StringVar(&txFormat, "format", "json", "transaction output format (for offline/unsigned modes) [json, cbor, safe]")
631635
RuntimeTxFlags.StringVarP(&txOutputFile, "output-file", "o", "", "output transaction into specified file instead of broadcasting")
632636

633637
TxFlags = flag.NewFlagSet("", flag.ContinueOnError)

cmd/tx.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ var (
111111
}
112112

113113
// Export signed transaction.
114-
common.ExportTransaction(sigTx)
114+
common.ExportTransaction(npa.Network, sigTx)
115115
},
116116
}
117117

@@ -136,6 +136,12 @@ var (
136136
)
137137

138138
func tryDecodeTx(rawTx []byte) (any, error) {
139+
// Check whether this is a wrapped Safe transaction first since none of the
140+
// formats below would otherwise recognize it.
141+
if tx, err := common.DecodeSafe(rawTx); err == nil {
142+
return tx, nil
143+
}
144+
139145
// Determine what kind of a transaction this is by attempting to decode it as either a
140146
// consensus layer transaction or a runtime transaction. Either could also be unsigned.
141147
txTypes := []struct {

docs/account.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -524,16 +524,21 @@ more.
524524

525525
### Output format {#format}
526526

527-
Use `--format json` or `--format cbor` to select the output file
528-
format. By default the JSON encoding is selected so that the file is
529-
human-readable and that 3rd party applications can easily manage it. If you want
530-
to output the transaction in the same format that will be stored on-chain or you
531-
are using a 3rd party tool for signing the content of the transaction file
532-
directly use the CBOR encoding.
533-
534-
This parameter only works together with [`--unsigned`] and/or
527+
Use `--format` with the following values:
528+
529+
- `json` (default): A human-readable format useful to examine the transaction or
530+
to import it into a 3rd party application.
531+
- `cbor`: The same transaction format that will be stored on-chain. Useful for
532+
signing it with Oasis CLI running on an air-gapped machine or with another
533+
external wallet.
534+
- `safe`: The Safe Transaction Builder batch that can be imported to
535+
[safe.oasis.io] for multisig and submission on Sapphire.
536+
537+
The `--format` parameter only works together with [`--unsigned`] and/or
535538
[`--output-file`] parameters.
536539

540+
[safe.oasis.io]: https://docs.oasis.io/general/manage-tokens/holding-rose-tokens/custody-providers#oasis-safe
541+
537542
### Offline Mode {#offline}
538543

539544
To generate a transaction without accessing the network and also without

0 commit comments

Comments
 (0)