diff --git a/cmd/account/deposit.go b/cmd/account/deposit.go index 31931fe2..af7924b3 100644 --- a/cmd/account/deposit.go +++ b/cmd/account/deposit.go @@ -76,7 +76,7 @@ var depositCmd = &cobra.Command{ cobra.CheckErr(err) if txCfg.Export { - common.ExportTransaction(sigTx) + common.ExportTransaction(npa.ParaTime, sigTx) return } diff --git a/cmd/account/withdraw.go b/cmd/account/withdraw.go index a6366588..db7eb1de 100644 --- a/cmd/account/withdraw.go +++ b/cmd/account/withdraw.go @@ -93,7 +93,7 @@ var withdrawCmd = &cobra.Command{ cobra.CheckErr(err) if txCfg.Export { - common.ExportTransaction(sigTx) + common.ExportTransaction(npa.ParaTime, sigTx) return } diff --git a/cmd/common/safe.go b/cmd/common/safe.go new file mode 100644 index 00000000..fbdcc1c7 --- /dev/null +++ b/cmd/common/safe.go @@ -0,0 +1,134 @@ +package common + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + ethAbi "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-core/go/common/quantity" + + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/config" + "github.com/oasisprotocol/oasis-sdk/client-sdk/go/types" +) + +// subcallAddress is the address of the "Subcall" precompile that dispatches +// ABI-encoded (method, CBOR body) calldata as an SDK runtime call. See +// https://github.com/oasisprotocol/oasis-sdk/blob/main/runtime-sdk/modules/evm/src/precompile/subcall.rs. +const subcallAddress = "0x0100000000000000000000000000000000000103" + +// safeTxBuilderTransaction is a single call within a Safe Transaction Builder batch. +type safeTxBuilderTransaction struct { + To string `json:"to"` + Value string `json:"value"` + Data string `json:"data"` + ContractMethod interface{} `json:"contractMethod"` + ContractInputsValues interface{} `json:"contractInputsValues"` +} + +// safeTxBuilderBatch is the file format understood by Safe's Transaction Builder app. +// See https://help.safe.global/en/articles/40841-transaction-builder. +type safeTxBuilderBatch struct { + Version string `json:"version"` + ChainID string `json:"chainId"` + CreatedAt int64 `json:"createdAt"` + Meta struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"meta"` + Transactions []safeTxBuilderTransaction `json:"transactions"` +} + +// exportSafe wraps an unsigned Oasis transaction into a calldata for the +// Subcall precompile, and packages it for Safe. +func exportSafe(pt *config.ParaTime, sigTx interface{}) ([]byte, error) { + tx, ok := sigTx.(*types.Transaction) + if !ok || pt == nil { + return nil, fmt.Errorf("--format safe is only supported for unsigned Sapphire transactions") + } + var chainID uint64 + switch pt.ID { + case config.DefaultNetworks.All["mainnet"].ParaTimes.All["sapphire"].ID: + chainID = 23294 // Sapphire Mainnet + case config.DefaultNetworks.All["testnet"].ParaTimes.All["sapphire"].ID: + chainID = 23295 // Sapphire Testnet + default: + return nil, fmt.Errorf("--format safe is only supported on Sapphire") + } + stringType, err := ethAbi.NewType("string", "", nil) + if err != nil { + return nil, err + } + bytesType, err := ethAbi.NewType("bytes", "", nil) + if err != nil { + return nil, err + } + calldata, err := ethAbi.Arguments{{Type: stringType}, {Type: bytesType}}.Pack(string(tx.Call.Method), []byte(tx.Call.Body)) + if err != nil { + return nil, fmt.Errorf("failed to ABI-encode subcall: %w", err) + } + + batch := safeTxBuilderBatch{ + Version: "1.0", + ChainID: strconv.FormatUint(chainID, 10), + CreatedAt: time.Now().UnixMilli(), + Transactions: []safeTxBuilderTransaction{{ + To: subcallAddress, + Value: "0", + Data: "0x" + hex.EncodeToString(calldata), + }}, + } + batch.Meta.Name = string(tx.Call.Method) + batch.Meta.Description = fmt.Sprintf("Oasis CLI-generated Subcall wrapping '%s' for proposal via Safe.", tx.Call.Method) + + return json.MarshalIndent(batch, "", " ") +} + +// DecodeSafe attempts to decode raw bytes as a Safe Transaction Builder batch +// and unwraps Subcall precompile calldata back into an unsigned ParaTime +// transaction. +// +// The resulting transaction only has Call.Method and Call.Body populated, since +// the Safe batch does not carry sender, nonce, or fee information. +func DecodeSafe(raw []byte) (*types.Transaction, error) { + var batch safeTxBuilderBatch + if err := json.Unmarshal(raw, &batch); err != nil { + return nil, err + } + if batch.Version == "" || len(batch.Transactions) != 1 { + return nil, fmt.Errorf("file not a Safe Transaction Builder batch with a single transaction") + } + + txn := batch.Transactions[0] + if !strings.EqualFold(txn.To, subcallAddress) { + return nil, fmt.Errorf("transaction does not call the Subcall precompile at %s", subcallAddress) + } + + calldata, err := hex.DecodeString(strings.TrimPrefix(txn.Data, "0x")) + if err != nil { + return nil, fmt.Errorf("malformed calldata: %w", err) + } + + stringTy, err := ethAbi.NewType("string", "", nil) + if err != nil { + return nil, err + } + bytesTy, err := ethAbi.NewType("bytes", "", nil) + if err != nil { + return nil, err + } + vals, err := (ethAbi.Arguments{{Type: stringTy}, {Type: bytesTy}}).Unpack(calldata) + if err != nil { + return nil, fmt.Errorf("failed to ABI-decode subcall data: %w", err) + } + + tx := &types.Transaction{Versioned: cbor.NewVersioned(types.LatestTransactionVersion)} + tx.Call.Method = types.MethodName(vals[0].(string)) + tx.Call.Body = vals[1].([]byte) + tx.AuthInfo.Fee.Amount = types.NewBaseUnits(*quantity.NewFromUint64(0), types.NativeDenomination) + return tx, nil +} diff --git a/cmd/common/transaction.go b/cmd/common/transaction.go index b94ea550..c04d54af 100644 --- a/cmd/common/transaction.go +++ b/cmd/common/transaction.go @@ -39,7 +39,7 @@ var ( txGasPrice string txFeeDenom string txEncrypted bool - txUnsigned bool + TxUnsigned bool txFormat string txOutputFile string ) @@ -50,6 +50,7 @@ const ( formatJSON = "json" formatCBOR = "cbor" + formatSafe = "safe" ) var ( @@ -79,7 +80,7 @@ func GetTransactionConfig() *TransactionConfig { // shouldExportTransaction returns true if the transaction should be exported instead of broadcast. func shouldExportTransaction() bool { - return txOffline || txUnsigned || txOutputFile != "" + return txOffline || TxUnsigned || txOutputFile != "" } // isRuntimeTx returns true, if given object is a signed or unsigned runtime transaction. @@ -183,7 +184,7 @@ func SignConsensusTransaction( if tx.Nonce == invalidNonce || tx.Fee.Gas == invalidGasLimit { return nil, fmt.Errorf("nonce and/or gas limit must be specified in offline mode") } - if txUnsigned { + if TxUnsigned { // Return an unsigned transaction. return tx, nil } @@ -340,7 +341,7 @@ func SignParaTimeTransaction( tx.Call = *encCall } - if txUnsigned { + if TxUnsigned { // Return an unsigned transaction. return tx, meta, nil } @@ -420,7 +421,7 @@ func PrintTransactionBeforeSigning(npa *NPASelection, tx interface{}, txDetails } // ExportTransaction exports a (signed) transaction based on configuration. -func ExportTransaction(sigTx interface{}) { +func ExportTransaction(pt *config.ParaTime, sigTx interface{}) { // Determine output destination. var err error outputFile := os.Stdout @@ -440,6 +441,9 @@ func ExportTransaction(sigTx interface{}) { cobra.CheckErr(err) case formatCBOR: data = cbor.Marshal(sigTx) + case formatSafe: + data, err = exportSafe(pt, sigTx) + cobra.CheckErr(err) default: cobra.CheckErr(fmt.Errorf("unknown transaction format: %s", txFormat)) } @@ -463,7 +467,7 @@ func BroadcastOrExportTransaction( result interface{}, ) bool { if shouldExportTransaction() { - ExportTransaction(tx) + ExportTransaction(npa.ParaTime, tx) return false } @@ -626,8 +630,8 @@ func init() { RuntimeTxFlags.StringVar(&txFeeDenom, "fee-denom", "", "override fee denomination (defaults to native)") RuntimeTxFlags.BoolVar(&txEncrypted, "encrypted", false, "encrypt transaction call data (requires online mode)") RuntimeTxFlags.AddFlagSet(AnswerYesFlag) - RuntimeTxFlags.BoolVar(&txUnsigned, "unsigned", false, "do not sign transaction") - RuntimeTxFlags.StringVar(&txFormat, "format", "json", "transaction output format (for offline/unsigned modes) [json, cbor]") + RuntimeTxFlags.BoolVar(&TxUnsigned, "unsigned", false, "do not sign transaction") + RuntimeTxFlags.StringVar(&txFormat, "format", "json", "transaction output format (for offline/unsigned modes) [json, cbor, safe]") RuntimeTxFlags.StringVarP(&txOutputFile, "output-file", "o", "", "output transaction into specified file instead of broadcasting") TxFlags = flag.NewFlagSet("", flag.ContinueOnError) @@ -636,7 +640,7 @@ func init() { TxFlags.Uint64Var(&txGasLimit, "gas-limit", invalidGasLimit, "override gas limit to use (disable estimation)") TxFlags.StringVar(&txGasPrice, "gas-price", "", "override gas price to use") TxFlags.AddFlagSet(AnswerYesFlag) - TxFlags.BoolVar(&txUnsigned, "unsigned", false, "do not sign transaction") + TxFlags.BoolVar(&TxUnsigned, "unsigned", false, "do not sign transaction") TxFlags.StringVar(&txFormat, "format", "json", "transaction output format (for offline/unsigned modes) [json, cbor]") TxFlags.StringVarP(&txOutputFile, "output-file", "o", "", "output transaction into specified file instead of broadcasting") } diff --git a/cmd/rofl/common/manifest.go b/cmd/rofl/common/manifest.go index eaca075f..5b611bcf 100644 --- a/cmd/rofl/common/manifest.go +++ b/cmd/rofl/common/manifest.go @@ -105,8 +105,8 @@ func MaybeLoadManifestAndSetNPA(cfg *cliConfig.Config, npa *common.NPASelection, } switch { case d.Admin == "": - // No admin in manifest, leave unchanged. - case npa.AccountSetExplicitly: + // No admin in manifest, leave unchanged; ignore for unsigned transactions entirely. + case npa.AccountSetExplicitly, common.TxUnsigned: // Account explicitly overridden on the command line, leave unchanged. default: accCfg, err := common.LoadAccountConfig(cfg, d.Admin) diff --git a/cmd/rofl/deploy.go b/cmd/rofl/deploy.go index 632d38be..6a2ff5a6 100644 --- a/cmd/rofl/deploy.go +++ b/cmd/rofl/deploy.go @@ -147,8 +147,6 @@ var ( return } - acc := common.LoadAccount(cliConfig.Global(), npa.AccountName) - ociRepository := ociRepository(deployment) orcFilename := roflCommon.GetOrcFilename(manifest, roflCommon.DeploymentName) fmt.Printf("Pushing ROFL app to OCI repository '%s'...\n", ociRepository) @@ -178,6 +176,10 @@ var ( machineDeployment.Metadata[scheduler.MetadataKeyProxyCustomDomains] = customDomains } + acc := common.LoadAccount(cliConfig.Global(), cliConfig.Global().Wallet.Default) + if !common.TxUnsigned { + acc = common.LoadAccount(cliConfig.Global(), npa.AccountName) + } obtainMachine := func() (*buildRofl.Machine, *roflmarket.Instance, error) { if deployOffer != "" { machine.Offer = deployOffer diff --git a/cmd/tx.go b/cmd/tx.go index a44ec613..d17716d2 100644 --- a/cmd/tx.go +++ b/cmd/tx.go @@ -111,7 +111,7 @@ var ( } // Export signed transaction. - common.ExportTransaction(sigTx) + common.ExportTransaction(npa.ParaTime, sigTx) }, } @@ -136,6 +136,12 @@ var ( ) func tryDecodeTx(rawTx []byte) (any, error) { + // Check whether this is a wrapped Safe transaction first since none of the + // formats below would otherwise recognize it. + if tx, err := common.DecodeSafe(rawTx); err == nil { + return tx, nil + } + // Determine what kind of a transaction this is by attempting to decode it as either a // consensus layer transaction or a runtime transaction. Either could also be unsigned. txTypes := []struct { diff --git a/docs/account.md b/docs/account.md index ff24b2ff..f82112e5 100644 --- a/docs/account.md +++ b/docs/account.md @@ -524,16 +524,21 @@ more. ### Output format {#format} -Use `--format json` or `--format cbor` to select the output file -format. By default the JSON encoding is selected so that the file is -human-readable and that 3rd party applications can easily manage it. If you want -to output the transaction in the same format that will be stored on-chain or you -are using a 3rd party tool for signing the content of the transaction file -directly use the CBOR encoding. - -This parameter only works together with [`--unsigned`] and/or +Use `--format` with the following values: + +- `json` (default): A human-readable format useful to examine the transaction or + to import it into a 3rd party application. +- `cbor`: The same transaction format that will be stored on-chain. Useful for + signing it with Oasis CLI running on an air-gapped machine or with another + external wallet. +- `safe`: The Safe Transaction Builder batch that can be imported to + [safe.oasis.io] for multisig and submission on Sapphire. + +The `--format` parameter only works together with [`--unsigned`] and/or [`--output-file`] parameters. +[safe.oasis.io]: https://docs.oasis.io/general/manage-tokens/holding-rose-tokens/custody-providers#oasis-safe + ### Offline Mode {#offline} To generate a transaction without accessing the network and also without diff --git a/go.sum b/go.sum index 6e62e147..6bdc9d81 100644 --- a/go.sum +++ b/go.sum @@ -268,6 +268,8 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=