Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/account/deposit.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ var depositCmd = &cobra.Command{
cobra.CheckErr(err)

if txCfg.Export {
common.ExportTransaction(sigTx)
common.ExportTransaction(npa.ParaTime, sigTx)
return
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/account/withdraw.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ var withdrawCmd = &cobra.Command{
cobra.CheckErr(err)

if txCfg.Export {
common.ExportTransaction(sigTx)
common.ExportTransaction(npa.ParaTime, sigTx)
return
}

Expand Down
134 changes: 134 additions & 0 deletions cmd/common/safe.go
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 13 additions & 9 deletions cmd/common/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ var (
txGasPrice string
txFeeDenom string
txEncrypted bool
txUnsigned bool
TxUnsigned bool
txFormat string
txOutputFile string
)
Expand All @@ -50,6 +50,7 @@ const (

formatJSON = "json"
formatCBOR = "cbor"
formatSafe = "safe"
)

var (
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -340,7 +341,7 @@ func SignParaTimeTransaction(
tx.Call = *encCall
}

if txUnsigned {
if TxUnsigned {
// Return an unsigned transaction.
return tx, meta, nil
}
Expand Down Expand Up @@ -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
Expand All @@ -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))
}
Expand All @@ -463,7 +467,7 @@ func BroadcastOrExportTransaction(
result interface{},
) bool {
if shouldExportTransaction() {
ExportTransaction(tx)
ExportTransaction(npa.ParaTime, tx)
return false
}

Expand Down Expand Up @@ -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)
Expand All @@ -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")
}
4 changes: 2 additions & 2 deletions cmd/rofl/common/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be pretty common for multisig ROFLs where no one will have a local admin account available. But creating and exporting ROFL transactions into Safe format must still work.

// Account explicitly overridden on the command line, leave unchanged.
default:
accCfg, err := common.LoadAccountConfig(cfg, d.Admin)
Expand Down
6 changes: 4 additions & 2 deletions cmd/rofl/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion cmd/tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ var (
}

// Export signed transaction.
common.ExportTransaction(sigTx)
common.ExportTransaction(npa.ParaTime, sigTx)
},
}

Expand All @@ -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 {
Expand Down
21 changes: 13 additions & 8 deletions docs/account.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading