Skip to content

Commit d374d97

Browse files
authored
Merge pull request #708 from oasisprotocol/matevz/feat/safe
cmd/tx: Implement Safe transaction batch output format
2 parents fe04d6c + 76a3545 commit d374d97

9 files changed

Lines changed: 177 additions & 24 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.ParaTime, 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.ParaTime, sigTx)
9797
return
9898
}
9999

cmd/common/safe.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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(pt *config.ParaTime, sigTx interface{}) ([]byte, error) {
49+
tx, ok := sigTx.(*types.Transaction)
50+
if !ok || pt == nil {
51+
return nil, fmt.Errorf("--format safe is only supported for unsigned Sapphire transactions")
52+
}
53+
var chainID uint64
54+
switch pt.ID {
55+
case config.DefaultNetworks.All["mainnet"].ParaTimes.All["sapphire"].ID:
56+
chainID = 23294 // Sapphire Mainnet
57+
case config.DefaultNetworks.All["testnet"].ParaTimes.All["sapphire"].ID:
58+
chainID = 23295 // Sapphire Testnet
59+
default:
60+
return nil, fmt.Errorf("--format safe is only supported on Sapphire")
61+
}
62+
stringType, err := ethAbi.NewType("string", "", nil)
63+
if err != nil {
64+
return nil, err
65+
}
66+
bytesType, err := ethAbi.NewType("bytes", "", nil)
67+
if err != nil {
68+
return nil, err
69+
}
70+
calldata, err := ethAbi.Arguments{{Type: stringType}, {Type: bytesType}}.Pack(string(tx.Call.Method), []byte(tx.Call.Body))
71+
if err != nil {
72+
return nil, fmt.Errorf("failed to ABI-encode subcall: %w", err)
73+
}
74+
75+
batch := safeTxBuilderBatch{
76+
Version: "1.0",
77+
ChainID: strconv.FormatUint(chainID, 10),
78+
CreatedAt: time.Now().UnixMilli(),
79+
Transactions: []safeTxBuilderTransaction{{
80+
To: subcallAddress,
81+
Value: "0",
82+
Data: "0x" + hex.EncodeToString(calldata),
83+
}},
84+
}
85+
batch.Meta.Name = string(tx.Call.Method)
86+
batch.Meta.Description = fmt.Sprintf("Oasis CLI-generated Subcall wrapping '%s' for proposal via Safe.", tx.Call.Method)
87+
88+
return json.MarshalIndent(batch, "", " ")
89+
}
90+
91+
// DecodeSafe attempts to decode raw bytes as a Safe Transaction Builder batch
92+
// and unwraps Subcall precompile calldata back into an unsigned ParaTime
93+
// transaction.
94+
//
95+
// The resulting transaction only has Call.Method and Call.Body populated, since
96+
// the Safe batch does not carry sender, nonce, or fee information.
97+
func DecodeSafe(raw []byte) (*types.Transaction, error) {
98+
var batch safeTxBuilderBatch
99+
if err := json.Unmarshal(raw, &batch); err != nil {
100+
return nil, err
101+
}
102+
if batch.Version == "" || len(batch.Transactions) != 1 {
103+
return nil, fmt.Errorf("file not a Safe Transaction Builder batch with a single transaction")
104+
}
105+
106+
txn := batch.Transactions[0]
107+
if !strings.EqualFold(txn.To, subcallAddress) {
108+
return nil, fmt.Errorf("transaction does not call the Subcall precompile at %s", subcallAddress)
109+
}
110+
111+
calldata, err := hex.DecodeString(strings.TrimPrefix(txn.Data, "0x"))
112+
if err != nil {
113+
return nil, fmt.Errorf("malformed calldata: %w", err)
114+
}
115+
116+
stringTy, err := ethAbi.NewType("string", "", nil)
117+
if err != nil {
118+
return nil, err
119+
}
120+
bytesTy, err := ethAbi.NewType("bytes", "", nil)
121+
if err != nil {
122+
return nil, err
123+
}
124+
vals, err := (ethAbi.Arguments{{Type: stringTy}, {Type: bytesTy}}).Unpack(calldata)
125+
if err != nil {
126+
return nil, fmt.Errorf("failed to ABI-decode subcall data: %w", err)
127+
}
128+
129+
tx := &types.Transaction{Versioned: cbor.NewVersioned(types.LatestTransactionVersion)}
130+
tx.Call.Method = types.MethodName(vals[0].(string))
131+
tx.Call.Body = vals[1].([]byte)
132+
tx.AuthInfo.Fee.Amount = types.NewBaseUnits(*quantity.NewFromUint64(0), types.NativeDenomination)
133+
return tx, nil
134+
}

cmd/common/transaction.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ var (
3939
txGasPrice string
4040
txFeeDenom string
4141
txEncrypted bool
42-
txUnsigned bool
42+
TxUnsigned bool
4343
txFormat string
4444
txOutputFile string
4545
)
@@ -50,6 +50,7 @@ const (
5050

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

5556
var (
@@ -79,7 +80,7 @@ func GetTransactionConfig() *TransactionConfig {
7980

8081
// shouldExportTransaction returns true if the transaction should be exported instead of broadcast.
8182
func shouldExportTransaction() bool {
82-
return txOffline || txUnsigned || txOutputFile != ""
83+
return txOffline || TxUnsigned || txOutputFile != ""
8384
}
8485

8586
// isRuntimeTx returns true, if given object is a signed or unsigned runtime transaction.
@@ -183,7 +184,7 @@ func SignConsensusTransaction(
183184
if tx.Nonce == invalidNonce || tx.Fee.Gas == invalidGasLimit {
184185
return nil, fmt.Errorf("nonce and/or gas limit must be specified in offline mode")
185186
}
186-
if txUnsigned {
187+
if TxUnsigned {
187188
// Return an unsigned transaction.
188189
return tx, nil
189190
}
@@ -340,7 +341,7 @@ func SignParaTimeTransaction(
340341
tx.Call = *encCall
341342
}
342343

343-
if txUnsigned {
344+
if TxUnsigned {
344345
// Return an unsigned transaction.
345346
return tx, meta, nil
346347
}
@@ -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(pt *config.ParaTime, 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(pt, 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.ParaTime, tx)
467471
return false
468472
}
469473

@@ -626,8 +630,8 @@ func init() {
626630
RuntimeTxFlags.StringVar(&txFeeDenom, "fee-denom", "", "override fee denomination (defaults to native)")
627631
RuntimeTxFlags.BoolVar(&txEncrypted, "encrypted", false, "encrypt transaction call data (requires online mode)")
628632
RuntimeTxFlags.AddFlagSet(AnswerYesFlag)
629-
RuntimeTxFlags.BoolVar(&txUnsigned, "unsigned", false, "do not sign transaction")
630-
RuntimeTxFlags.StringVar(&txFormat, "format", "json", "transaction output format (for offline/unsigned modes) [json, cbor]")
633+
RuntimeTxFlags.BoolVar(&TxUnsigned, "unsigned", false, "do not sign transaction")
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)
@@ -636,7 +640,7 @@ func init() {
636640
TxFlags.Uint64Var(&txGasLimit, "gas-limit", invalidGasLimit, "override gas limit to use (disable estimation)")
637641
TxFlags.StringVar(&txGasPrice, "gas-price", "", "override gas price to use")
638642
TxFlags.AddFlagSet(AnswerYesFlag)
639-
TxFlags.BoolVar(&txUnsigned, "unsigned", false, "do not sign transaction")
643+
TxFlags.BoolVar(&TxUnsigned, "unsigned", false, "do not sign transaction")
640644
TxFlags.StringVar(&txFormat, "format", "json", "transaction output format (for offline/unsigned modes) [json, cbor]")
641645
TxFlags.StringVarP(&txOutputFile, "output-file", "o", "", "output transaction into specified file instead of broadcasting")
642646
}

cmd/rofl/common/manifest.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ func MaybeLoadManifestAndSetNPA(cfg *cliConfig.Config, npa *common.NPASelection,
105105
}
106106
switch {
107107
case d.Admin == "":
108-
// No admin in manifest, leave unchanged.
109-
case npa.AccountSetExplicitly:
108+
// No admin in manifest, leave unchanged; ignore for unsigned transactions entirely.
109+
case npa.AccountSetExplicitly, common.TxUnsigned:
110110
// Account explicitly overridden on the command line, leave unchanged.
111111
default:
112112
accCfg, err := common.LoadAccountConfig(cfg, d.Admin)

cmd/rofl/deploy.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,6 @@ var (
147147
return
148148
}
149149

150-
acc := common.LoadAccount(cliConfig.Global(), npa.AccountName)
151-
152150
ociRepository := ociRepository(deployment)
153151
orcFilename := roflCommon.GetOrcFilename(manifest, roflCommon.DeploymentName)
154152
fmt.Printf("Pushing ROFL app to OCI repository '%s'...\n", ociRepository)
@@ -178,6 +176,10 @@ var (
178176
machineDeployment.Metadata[scheduler.MetadataKeyProxyCustomDomains] = customDomains
179177
}
180178

179+
acc := common.LoadAccount(cliConfig.Global(), cliConfig.Global().Wallet.Default)
180+
if !common.TxUnsigned {
181+
acc = common.LoadAccount(cliConfig.Global(), npa.AccountName)
182+
}
181183
obtainMachine := func() (*buildRofl.Machine, *roflmarket.Instance, error) {
182184
if deployOffer != "" {
183185
machine.Offer = deployOffer

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.ParaTime, 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

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,8 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
268268
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
269269
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
270270
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
271+
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
272+
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
271273
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
272274
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
273275
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=

0 commit comments

Comments
 (0)