11package blockchain
22
33import (
4+ "bytes"
45 "context"
56 "encoding/json"
67 "fmt"
78 "path/filepath"
9+ "runtime"
810 "strings"
911 "time"
1012
1113 "github.com/block-vision/sui-go-sdk/models"
1214 "github.com/docker/docker/api/types/container"
15+ "github.com/docker/docker/pkg/stdcopy"
1316 "github.com/docker/go-connections/nat"
1417 "github.com/go-resty/resty/v2"
1518 "github.com/testcontainers/testcontainers-go"
@@ -23,6 +26,10 @@ const (
2326 DefaultFaucetPort = "9123/tcp"
2427 DefaultFaucetPortNum = "9123"
2528 DefaultSuiNodePort = "9000"
29+ // DefaultSuiImage is the mysten/sui-tools image when Input.Image is empty on non-arm64 hosts.
30+ DefaultSuiImage = "mysten/sui-tools:devnet-v1.69.0"
31+ // DefaultSuiImageARM64 is used when Input.Image is empty on arm64 (e.g. Apple Silicon).
32+ DefaultSuiImageARM64 = "mysten/sui-tools:ci-arm64"
2633)
2734
2835// SuiWalletInfo info about Sui account/wallet
@@ -54,34 +61,95 @@ func fundAccount(url string, address string) error {
5461 return nil
5562}
5663
64+ // demuxDockerExecOutput converts Docker exec attach output to plain text when it uses the
65+ // multiplexed stream format (first byte 1=stdout / 2=stderr). Must run before stripping 0x01,
66+ // which appears in stream headers and would corrupt the stream if removed globally.
67+ func demuxDockerExecOutput (raw string ) string {
68+ if len (raw ) == 0 {
69+ return raw
70+ }
71+ if raw [0 ] != 1 && raw [0 ] != 2 {
72+ return raw
73+ }
74+ var stdout , stderr bytes.Buffer
75+ if _ , err := stdcopy .StdCopy (& stdout , & stderr , strings .NewReader (raw )); err != nil {
76+ return raw
77+ }
78+ out := stdout .String () + stderr .String ()
79+ // Invalid or partial multiplex streams can make StdCopy succeed with empty output; keep raw so
80+ // parseSuiKeytoolGenerateJSON can still find JSON after a single-byte preamble (e.g. 0x01).
81+ if out == "" {
82+ return raw
83+ }
84+
85+ return out
86+ }
87+
88+ // parseSuiKeytoolGenerateJSON extracts a SuiWalletInfo from `sui keytool generate --json` output.
89+ // The CLI may print a preamble, and v1.69+ may emit compact one-line JSON; older parsers assumed a
90+ // legacy layout (newline after '{') and corrupt compact output.
91+ func parseSuiKeytoolGenerateJSON (keyOut string ) (* SuiWalletInfo , error ) {
92+ text := demuxDockerExecOutput (keyOut )
93+ s := strings .ReplaceAll (text , "\x00 " , "" )
94+ for i := range s {
95+ if s [i ] != '{' {
96+ continue
97+ }
98+ var key SuiWalletInfo
99+ dec := json .NewDecoder (bytes .NewReader ([]byte (s [i :])))
100+ if err := dec .Decode (& key ); err != nil {
101+ continue
102+ }
103+ if key .SuiAddress != "" {
104+ return & key , nil
105+ }
106+ }
107+
108+ return nil , fmt .Errorf ("failed to parse SuiWalletInfo from keytool output: %.200q" , keyOut )
109+ }
110+
57111// generateKeyData generates a wallet and returns all the data
58112func generateKeyData (ctx context.Context , containerName string , keyCipherType string ) (* SuiWalletInfo , error ) {
59- cmdStr := []string {"sui" , "keytool" , "generate" , keyCipherType , "--json" }
60113 dc , err := framework .NewDockerClient ()
61114 if err != nil {
62115 return nil , err
63116 }
117+
118+ // Ensure a valid Sui client config exists. `sui start --force-regenesis`
119+ // creates its config under /root/.sui/sui_config/ but the client.yaml it
120+ // generates may not exist yet when this runs, so we use `sui client --yes`
121+ // with an explicit config flag to force creation.
122+ initCmd := []string {"sui" , "client" , "--client.config" , "/root/.sui/sui_config/client.yaml" , "--yes" , "envs" }
123+ if initOut , initErr := dc .ExecContainerWithContext (ctx , containerName , initCmd ); initErr != nil {
124+ framework .L .Warn ().Err (initErr ).Str ("out" , initOut ).Msg ("sui client init returned error (may be harmless)" )
125+ }
126+
127+ cmdStr := []string {"sui" , "keytool" , "generate" , keyCipherType , "--json" }
64128 keyOut , err := dc .ExecContainerWithContext (ctx , containerName , cmdStr )
65129 if err != nil {
66130 return nil , err
67131 }
68- // formatted JSON with, no plain --json version, remove special symbols
69- cleanKey := strings .ReplaceAll (keyOut , "\x00 " , "" )
70- cleanKey = strings .ReplaceAll (cleanKey , "\x01 " , "" )
71- cleanKey = strings .ReplaceAll (cleanKey , "\x02 " , "" )
72- cleanKey = strings .ReplaceAll (cleanKey , "\n " , "" )
73- cleanKey = "{" + cleanKey [2 :]
74- var key * SuiWalletInfo
75- if err := json .Unmarshal ([]byte (cleanKey ), & key ); err != nil {
76- return nil , err
132+ key , err := parseSuiKeytoolGenerateJSON (keyOut )
133+ if err != nil {
134+ return nil , fmt .Errorf ("%w (raw output: %.300q)" , err , keyOut )
77135 }
78- framework .L .Info ().Interface ("Key" , key ).Msg ("Test key" )
136+
137+ framework .L .Info ().Str ("suiAddress" , key .SuiAddress ).Msg ("CTF test key generated" )
138+
79139 return key , nil
80140}
81141
82142func defaultSui (in * Input ) {
83143 if in .Image == "" {
84- in .Image = "mysten/sui-tools:devnet-v1.68.0"
144+ if runtime .GOARCH == "arm64" {
145+ in .Image = DefaultSuiImageARM64
146+ if in .ImagePlatform == nil {
147+ arm := "linux/arm64"
148+ in .ImagePlatform = & arm
149+ }
150+ } else {
151+ in .Image = DefaultSuiImage
152+ }
85153 }
86154 if in .Port == "" {
87155 in .Port = DefaultSuiNodePort
@@ -95,17 +163,25 @@ func newSui(ctx context.Context, in *Input) (*Output, error) {
95163 defaultSui (in )
96164 containerName := framework .DefaultTCName ("blockchain-node" )
97165
98- absPath , err := filepath .Abs (in .ContractsDir )
99- if err != nil {
100- return nil , err
166+ var files []testcontainers.ContainerFile
167+ if in .ContractsDir != "" {
168+ absPath , err := filepath .Abs (in .ContractsDir )
169+ if err != nil {
170+ return nil , err
171+ }
172+ files = []testcontainers.ContainerFile {
173+ {
174+ HostFilePath : absPath ,
175+ ContainerFilePath : "/" ,
176+ },
177+ }
101178 }
102179
103180 // Sui container always listens on port 9000 internally
104181 containerPort := fmt .Sprintf ("%s/tcp" , DefaultSuiNodePort )
105182
106- // default to amd64, unless otherwise specified
107183 imagePlatform := "linux/amd64"
108- if in .ImagePlatform != nil {
184+ if in .ImagePlatform != nil && * in . ImagePlatform != "" {
109185 imagePlatform = * in .ImagePlatform
110186 }
111187
@@ -150,13 +226,7 @@ func newSui(ctx context.Context, in *Input) (*Output, error) {
150226 "--force-regenesis" ,
151227 "--with-faucet" ,
152228 },
153- Files : []testcontainers.ContainerFile {
154- {
155- HostFilePath : absPath ,
156- ContainerFilePath : "/" ,
157- },
158- },
159- // we need faucet for funding
229+ Files : files ,
160230 WaitingFor : wait .ForListeningPort (DefaultFaucetPort ).WithStartupTimeout (1 * time .Minute ).WithPollInterval (200 * time .Millisecond ),
161231 }
162232
@@ -183,6 +253,7 @@ func newSui(ctx context.Context, in *Input) (*Output, error) {
183253 Type : in .Type ,
184254 Family : FamilySui ,
185255 ContainerName : containerName ,
256+ Container : c ,
186257 NetworkSpecificData : & NetworkSpecificData {SuiAccount : suiAccount },
187258 Nodes : []* Node {
188259 {
0 commit comments