Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
N
nebula
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
exchain
nebula
Commits
114127bc
Unverified
Commit
114127bc
authored
Aug 22, 2023
by
mergify[bot]
Committed by
GitHub
Aug 22, 2023
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 'develop' into aj/skip-complete-games
parents
27d45ee0
e8af7c66
Changes
9
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
115 additions
and
31 deletions
+115
-31
main_test.go
op-challenger/cmd/main_test.go
+18
-0
config.go
op-challenger/config/config.go
+12
-3
factory.go
op-challenger/fault/factory.go
+11
-6
factory_test.go
op-challenger/fault/factory_test.go
+11
-8
monitor.go
op-challenger/fault/monitor.go
+17
-3
monitor_test.go
op-challenger/fault/monitor_test.go
+29
-2
service.go
op-challenger/fault/service.go
+1
-1
flags.go
op-challenger/flags/flags.go
+8
-0
pnpm-lock.yaml
pnpm-lock.yaml
+8
-8
No files found.
op-challenger/cmd/main_test.go
View file @
114127bc
...
...
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"testing"
"time"
"github.com/ethereum-optimism/optimism/op-challenger/config"
"github.com/ethereum-optimism/optimism/op-node/chaincfg"
...
...
@@ -233,6 +234,23 @@ func TestCannonSnapshotFreq(t *testing.T) {
})
}
func
TestGameWindow
(
t
*
testing
.
T
)
{
t
.
Run
(
"UsesDefault"
,
func
(
t
*
testing
.
T
)
{
cfg
:=
configForArgs
(
t
,
addRequiredArgs
(
config
.
TraceTypeAlphabet
))
require
.
Equal
(
t
,
config
.
DefaultGameWindow
,
cfg
.
GameWindow
)
})
t
.
Run
(
"Valid"
,
func
(
t
*
testing
.
T
)
{
cfg
:=
configForArgs
(
t
,
addRequiredArgs
(
config
.
TraceTypeAlphabet
,
"--game-window=1m"
))
require
.
Equal
(
t
,
time
.
Duration
(
time
.
Minute
),
cfg
.
GameWindow
)
})
t
.
Run
(
"ParsesDefault"
,
func
(
t
*
testing
.
T
)
{
cfg
:=
configForArgs
(
t
,
addRequiredArgs
(
config
.
TraceTypeAlphabet
,
"--game-window=264h"
))
require
.
Equal
(
t
,
config
.
DefaultGameWindow
,
cfg
.
GameWindow
)
})
}
func
TestRequireEitherCannonNetworkOrRollupAndGenesis
(
t
*
testing
.
T
)
{
verifyArgsInvalid
(
t
,
...
...
op-challenger/config/config.go
View file @
114127bc
...
...
@@ -3,6 +3,7 @@ package config
import
(
"errors"
"fmt"
"time"
"github.com/ethereum/go-ethereum/common"
...
...
@@ -73,7 +74,14 @@ func ValidTraceType(value TraceType) bool {
return
false
}
const
DefaultCannonSnapshotFreq
=
uint
(
1
_000_000_000
)
const
(
DefaultCannonSnapshotFreq
=
uint
(
1
_000_000_000
)
// DefaultGameWindow is the default maximum time duration in the past
// that the challenger will look for games to progress.
// The default value is 11 days, which is a 4 day resolution buffer
// plus the 7 day game finalization window.
DefaultGameWindow
=
time
.
Duration
(
11
*
24
*
time
.
Hour
)
)
// Config is a well typed config that is parsed from the CLI params.
// This also contains config options for auxiliary services.
...
...
@@ -82,9 +90,9 @@ type Config struct {
L1EthRpc
string
// L1 RPC Url
GameFactoryAddress
common
.
Address
// Address of the dispute game factory
GameAllowlist
[]
common
.
Address
// Allowlist of fault game addresses
GameWindow
time
.
Duration
// Maximum time duration to look for games to progress
AgreeWithProposedOutput
bool
// Temporary config if we agree or disagree with the posted output
TraceType
TraceType
// Type of trace
TraceType
TraceType
// Type of trace
// Specific to the alphabet trace provider
AlphabetTrace
string
// String for the AlphabetTraceProvider
...
...
@@ -124,6 +132,7 @@ func NewConfig(
PprofConfig
:
oppprof
.
DefaultCLIConfig
(),
CannonSnapshotFreq
:
DefaultCannonSnapshotFreq
,
GameWindow
:
DefaultGameWindow
,
}
}
...
...
op-challenger/fault/factory.go
View file @
114127bc
...
...
@@ -43,7 +43,7 @@ func NewGameLoader(caller MinimalDisputeGameFactoryCaller) *gameLoader {
}
// FetchAllGamesAtBlock fetches all dispute games from the factory at a given block number.
func
(
l
*
gameLoader
)
FetchAllGamesAtBlock
(
ctx
context
.
Context
,
blockNumber
*
big
.
Int
)
([]
FaultDisputeGame
,
error
)
{
func
(
l
*
gameLoader
)
FetchAllGamesAtBlock
(
ctx
context
.
Context
,
earliestTimestamp
uint64
,
blockNumber
*
big
.
Int
)
([]
FaultDisputeGame
,
error
)
{
if
blockNumber
==
nil
{
return
nil
,
ErrMissingBlockNumber
}
...
...
@@ -56,14 +56,19 @@ func (l *gameLoader) FetchAllGamesAtBlock(ctx context.Context, blockNumber *big.
return
nil
,
fmt
.
Errorf
(
"failed to fetch game count: %w"
,
err
)
}
games
:=
make
([]
FaultDisputeGame
,
gameCount
.
Uint64
())
for
i
:=
uint64
(
0
);
i
<
gameCount
.
Uint64
();
i
++
{
game
,
err
:=
l
.
caller
.
GameAtIndex
(
callOpts
,
big
.
NewInt
(
int64
(
i
)))
games
:=
make
([]
FaultDisputeGame
,
0
)
if
gameCount
.
Uint64
()
==
0
{
return
games
,
nil
}
for
i
:=
gameCount
.
Uint64
();
i
>
0
;
i
--
{
game
,
err
:=
l
.
caller
.
GameAtIndex
(
callOpts
,
big
.
NewInt
(
int64
(
i
-
1
)))
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"failed to fetch game at index %d: %w"
,
i
,
err
)
}
games
[
i
]
=
game
if
game
.
Timestamp
<
earliestTimestamp
{
break
}
games
=
append
(
games
,
game
)
}
return
games
,
nil
...
...
op-challenger/fault/factory_test.go
View file @
114127bc
...
...
@@ -25,6 +25,7 @@ func TestGameLoader_FetchAllGames(t *testing.T) {
tests
:=
[]
struct
{
name
string
caller
*
mockMinimalDisputeGameFactoryCaller
earliest
uint64
blockNumber
*
big
.
Int
expectedErr
error
expectedLen
int
...
...
@@ -33,35 +34,36 @@ func TestGameLoader_FetchAllGames(t *testing.T) {
name
:
"success"
,
caller
:
newMockMinimalDisputeGameFactoryCaller
(
10
,
false
,
false
),
blockNumber
:
big
.
NewInt
(
1
),
expectedErr
:
nil
,
expectedLen
:
10
,
},
{
name
:
"expired game ignored"
,
caller
:
newMockMinimalDisputeGameFactoryCaller
(
10
,
false
,
false
),
earliest
:
500
,
blockNumber
:
big
.
NewInt
(
1
),
expectedLen
:
5
,
},
{
name
:
"game count error"
,
caller
:
newMockMinimalDisputeGameFactoryCaller
(
10
,
true
,
false
),
blockNumber
:
big
.
NewInt
(
1
),
expectedErr
:
gameCountErr
,
expectedLen
:
0
,
},
{
name
:
"game index error"
,
caller
:
newMockMinimalDisputeGameFactoryCaller
(
10
,
false
,
true
),
blockNumber
:
big
.
NewInt
(
1
),
expectedErr
:
gameIndexErr
,
expectedLen
:
0
,
},
{
name
:
"no games"
,
caller
:
newMockMinimalDisputeGameFactoryCaller
(
0
,
false
,
false
),
blockNumber
:
big
.
NewInt
(
1
),
expectedErr
:
nil
,
expectedLen
:
0
,
},
{
name
:
"missing block number"
,
caller
:
newMockMinimalDisputeGameFactoryCaller
(
0
,
false
,
false
),
expectedErr
:
ErrMissingBlockNumber
,
expectedLen
:
0
,
},
}
...
...
@@ -72,10 +74,11 @@ func TestGameLoader_FetchAllGames(t *testing.T) {
t
.
Parallel
()
loader
:=
NewGameLoader
(
test
.
caller
)
games
,
err
:=
loader
.
FetchAllGamesAtBlock
(
context
.
Background
(),
test
.
blockNumber
)
games
,
err
:=
loader
.
FetchAllGamesAtBlock
(
context
.
Background
(),
test
.
earliest
,
test
.
blockNumber
)
require
.
ErrorIs
(
t
,
err
,
test
.
expectedErr
)
require
.
Len
(
t
,
games
,
test
.
expectedLen
)
expectedGames
:=
test
.
caller
.
games
expectedGames
=
expectedGames
[
len
(
expectedGames
)
-
test
.
expectedLen
:
]
if
test
.
expectedErr
!=
nil
{
expectedGames
=
make
([]
FaultDisputeGame
,
0
)
}
...
...
@@ -90,7 +93,7 @@ func generateMockGames(count uint64) []FaultDisputeGame {
for
i
:=
uint64
(
0
);
i
<
count
;
i
++
{
games
[
i
]
=
FaultDisputeGame
{
Proxy
:
common
.
BigToAddress
(
big
.
NewInt
(
int64
(
i
))),
Timestamp
:
i
,
Timestamp
:
i
*
100
,
}
}
...
...
op-challenger/fault/monitor.go
View file @
114127bc
...
...
@@ -20,24 +20,26 @@ type blockNumberFetcher func(ctx context.Context) (uint64, error)
// gameSource loads information about the games available to play
type
gameSource
interface
{
FetchAllGamesAtBlock
(
ctx
context
.
Context
,
blockNumber
*
big
.
Int
)
([]
FaultDisputeGame
,
error
)
FetchAllGamesAtBlock
(
ctx
context
.
Context
,
earliest
uint64
,
blockNumber
*
big
.
Int
)
([]
FaultDisputeGame
,
error
)
}
type
gameMonitor
struct
{
logger
log
.
Logger
clock
clock
.
Clock
source
gameSource
gameWindow
time
.
Duration
createPlayer
playerCreator
fetchBlockNumber
blockNumberFetcher
allowedGames
[]
common
.
Address
players
map
[
common
.
Address
]
gamePlayer
}
func
newGameMonitor
(
logger
log
.
Logger
,
cl
clock
.
Clock
,
fetchBlockNumber
blockNumberFetcher
,
allowedGames
[]
common
.
Address
,
source
gameSource
,
createGame
playerCreator
)
*
gameMonitor
{
func
newGameMonitor
(
logger
log
.
Logger
,
gameWindow
time
.
Duration
,
cl
clock
.
Clock
,
fetchBlockNumber
blockNumberFetcher
,
allowedGames
[]
common
.
Address
,
source
gameSource
,
createGame
playerCreator
)
*
gameMonitor
{
return
&
gameMonitor
{
logger
:
logger
,
clock
:
cl
,
source
:
source
,
gameWindow
:
gameWindow
,
createPlayer
:
createGame
,
fetchBlockNumber
:
fetchBlockNumber
,
allowedGames
:
allowedGames
,
...
...
@@ -57,12 +59,24 @@ func (m *gameMonitor) allowedGame(game common.Address) bool {
return
false
}
func
(
m
*
gameMonitor
)
minGameTimestamp
()
uint64
{
if
m
.
gameWindow
.
Seconds
()
==
0
{
return
0
}
// time: "To compute t-d for a duration d, use t.Add(-d)."
// https://pkg.go.dev/time#Time.Sub
if
m
.
clock
.
Now
()
.
Unix
()
>
int64
(
m
.
gameWindow
.
Seconds
())
{
return
uint64
(
m
.
clock
.
Now
()
.
Add
(
-
m
.
gameWindow
)
.
Unix
())
}
return
0
}
func
(
m
*
gameMonitor
)
progressGames
(
ctx
context
.
Context
)
error
{
blockNum
,
err
:=
m
.
fetchBlockNumber
(
ctx
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"failed to load current block number: %w"
,
err
)
}
games
,
err
:=
m
.
source
.
FetchAllGamesAtBlock
(
ctx
,
new
(
big
.
Int
)
.
SetUint64
(
blockNum
))
games
,
err
:=
m
.
source
.
FetchAllGamesAtBlock
(
ctx
,
m
.
minGameTimestamp
(),
new
(
big
.
Int
)
.
SetUint64
(
blockNum
))
if
err
!=
nil
{
return
fmt
.
Errorf
(
"failed to load games: %w"
,
err
)
}
...
...
op-challenger/fault/monitor_test.go
View file @
114127bc
...
...
@@ -4,6 +4,7 @@ import (
"context"
"math/big"
"testing"
"time"
"github.com/ethereum-optimism/optimism/op-service/clock"
"github.com/ethereum/go-ethereum/common"
...
...
@@ -13,6 +14,32 @@ import (
"github.com/ethereum-optimism/optimism/op-node/testlog"
)
func
TestMonitorMinGameTimestamp
(
t
*
testing
.
T
)
{
t
.
Parallel
()
t
.
Run
(
"zero game window returns zero"
,
func
(
t
*
testing
.
T
)
{
monitor
,
_
,
_
:=
setupMonitorTest
(
t
,
[]
common
.
Address
{})
monitor
.
gameWindow
=
time
.
Duration
(
0
)
require
.
Equal
(
t
,
monitor
.
minGameTimestamp
(),
uint64
(
0
))
})
t
.
Run
(
"non-zero game window with zero clock"
,
func
(
t
*
testing
.
T
)
{
monitor
,
_
,
_
:=
setupMonitorTest
(
t
,
[]
common
.
Address
{})
monitor
.
gameWindow
=
time
.
Minute
monitor
.
clock
=
clock
.
NewDeterministicClock
(
time
.
Unix
(
0
,
0
))
require
.
Equal
(
t
,
monitor
.
minGameTimestamp
(),
uint64
(
0
))
})
t
.
Run
(
"minimum computed correctly"
,
func
(
t
*
testing
.
T
)
{
monitor
,
_
,
_
:=
setupMonitorTest
(
t
,
[]
common
.
Address
{})
monitor
.
gameWindow
=
time
.
Minute
frozen
:=
time
.
Unix
(
int64
(
time
.
Hour
.
Seconds
()),
0
)
monitor
.
clock
=
clock
.
NewDeterministicClock
(
frozen
)
expected
:=
uint64
(
frozen
.
Add
(
-
time
.
Minute
)
.
Unix
())
require
.
Equal
(
t
,
monitor
.
minGameTimestamp
(),
expected
)
})
}
func
TestMonitorExitsWhenContextDone
(
t
*
testing
.
T
)
{
monitor
,
_
,
_
:=
setupMonitorTest
(
t
,
[]
common
.
Address
{
common
.
Address
{}})
ctx
,
cancel
:=
context
.
WithCancel
(
context
.
Background
())
...
...
@@ -129,7 +156,7 @@ func setupMonitorTest(t *testing.T, allowedGames []common.Address) (*gameMonitor
fetchBlockNum
:=
func
(
ctx
context
.
Context
)
(
uint64
,
error
)
{
return
1234
,
nil
}
monitor
:=
newGameMonitor
(
logger
,
clock
.
SystemClock
,
fetchBlockNum
,
allowedGames
,
source
,
games
.
CreateGame
)
monitor
:=
newGameMonitor
(
logger
,
time
.
Duration
(
0
),
clock
.
SystemClock
,
fetchBlockNum
,
allowedGames
,
source
,
games
.
CreateGame
)
return
monitor
,
source
,
games
}
...
...
@@ -137,7 +164,7 @@ type stubGameSource struct {
games
[]
FaultDisputeGame
}
func
(
s
*
stubGameSource
)
FetchAllGamesAtBlock
(
ctx
context
.
Context
,
blockNumber
*
big
.
Int
)
([]
FaultDisputeGame
,
error
)
{
func
(
s
*
stubGameSource
)
FetchAllGamesAtBlock
(
ctx
context
.
Context
,
earliest
uint64
,
blockNumber
*
big
.
Int
)
([]
FaultDisputeGame
,
error
)
{
return
s
.
games
,
nil
}
...
...
op-challenger/fault/service.go
View file @
114127bc
...
...
@@ -73,7 +73,7 @@ func NewService(ctx context.Context, logger log.Logger, cfg *config.Config) (*se
}
loader
:=
NewGameLoader
(
factory
)
monitor
:=
newGameMonitor
(
logger
,
cl
,
client
.
BlockNumber
,
cfg
.
GameAllowlist
,
loader
,
func
(
addr
common
.
Address
)
(
gamePlayer
,
error
)
{
monitor
:=
newGameMonitor
(
logger
,
c
fg
.
GameWindow
,
c
l
,
client
.
BlockNumber
,
cfg
.
GameAllowlist
,
loader
,
func
(
addr
common
.
Address
)
(
gamePlayer
,
error
)
{
return
NewGamePlayer
(
ctx
,
logger
,
cfg
,
addr
,
txMgr
,
client
)
})
...
...
op-challenger/flags/flags.go
View file @
114127bc
...
...
@@ -109,6 +109,12 @@ var (
EnvVars
:
prefixEnvVars
(
"CANNON_SNAPSHOT_FREQ"
),
Value
:
config
.
DefaultCannonSnapshotFreq
,
}
GameWindowFlag
=
&
cli
.
DurationFlag
{
Name
:
"game-window"
,
Usage
:
"The time window which the challenger will look for games to progress."
,
EnvVars
:
prefixEnvVars
(
"GAME_WINDOW"
),
Value
:
config
.
DefaultGameWindow
,
}
)
// requiredFlags are checked by [CheckRequired]
...
...
@@ -132,6 +138,7 @@ var optionalFlags = []cli.Flag{
CannonDatadirFlag
,
CannonL2Flag
,
CannonSnapshotFreqFlag
,
GameWindowFlag
,
}
func
init
()
{
...
...
@@ -222,6 +229,7 @@ func NewConfigFromCLI(ctx *cli.Context) (*config.Config, error) {
TraceType
:
traceTypeFlag
,
GameFactoryAddress
:
gameFactoryAddress
,
GameAllowlist
:
allowedGames
,
GameWindow
:
ctx
.
Duration
(
GameWindowFlag
.
Name
),
AlphabetTrace
:
ctx
.
String
(
AlphabetFlag
.
Name
),
CannonNetwork
:
ctx
.
String
(
CannonNetworkFlag
.
Name
),
CannonRollupConfigPath
:
ctx
.
String
(
CannonRollupConfigFlag
.
Name
),
...
...
pnpm-lock.yaml
View file @
114127bc
...
...
@@ -1005,7 +1005,7 @@ packages:
/@changesets/apply-release-plan@6.1.3
:
resolution
:
{
integrity
:
sha512-ECDNeoc3nfeAe1jqJb5aFQX7CqzQhD2klXRez2JDb/aVpGUbX673HgKrnrgJRuQR/9f2TtLoYIzrGB9qwD77mg==
}
dependencies
:
'
@babel/runtime'
:
7.2
0.7
'
@babel/runtime'
:
7.2
2.6
'
@changesets/config'
:
2.3.0
'
@changesets/get-version-range-type'
:
0.3.2
'
@changesets/git'
:
2.0.0
...
...
@@ -1023,7 +1023,7 @@ packages:
/@changesets/assemble-release-plan@5.2.3
:
resolution
:
{
integrity
:
sha512-g7EVZCmnWz3zMBAdrcKhid4hkHT+Ft1n0mLussFMcB1dE2zCuwcvGoy9ec3yOgPGF4hoMtgHaMIk3T3TBdvU9g==
}
dependencies
:
'
@babel/runtime'
:
7.2
0.7
'
@babel/runtime'
:
7.2
2.6
'
@changesets/errors'
:
0.1.4
'
@changesets/get-dependents-graph'
:
1.3.5
'
@changesets/types'
:
5.2.1
...
...
@@ -1126,7 +1126,7 @@ packages:
/@changesets/get-release-plan@3.0.16
:
resolution
:
{
integrity
:
sha512-OpP9QILpBp1bY2YNIKFzwigKh7Qe9KizRsZomzLe6pK8IUo8onkAAVUD8+JRKSr8R7d4+JRuQrfSSNlEwKyPYg==
}
dependencies
:
'
@babel/runtime'
:
7.2
0.7
'
@babel/runtime'
:
7.2
2.6
'
@changesets/assemble-release-plan'
:
5.2.3
'
@changesets/config'
:
2.3.0
'
@changesets/pre'
:
1.0.14
...
...
@@ -1142,7 +1142,7 @@ packages:
/@changesets/git@2.0.0
:
resolution
:
{
integrity
:
sha512-enUVEWbiqUTxqSnmesyJGWfzd51PY4H7mH9yUw0hPVpZBJ6tQZFMU3F3mT/t9OJ/GjyiM4770i+sehAn6ymx6A==
}
dependencies
:
'
@babel/runtime'
:
7.2
0.7
'
@babel/runtime'
:
7.2
2.6
'
@changesets/errors'
:
0.1.4
'
@changesets/types'
:
5.2.1
'
@manypkg/get-packages'
:
1.1.3
...
...
@@ -1167,7 +1167,7 @@ packages:
/@changesets/pre@1.0.14
:
resolution
:
{
integrity
:
sha512-dTsHmxQWEQekHYHbg+M1mDVYFvegDh9j/kySNuDKdylwfMEevTeDouR7IfHNyVodxZXu17sXoJuf2D0vi55FHQ==
}
dependencies
:
'
@babel/runtime'
:
7.2
0.7
'
@babel/runtime'
:
7.2
2.6
'
@changesets/errors'
:
0.1.4
'
@changesets/types'
:
5.2.1
'
@manypkg/get-packages'
:
1.1.3
...
...
@@ -1177,7 +1177,7 @@ packages:
/@changesets/read@0.5.9
:
resolution
:
{
integrity
:
sha512-T8BJ6JS6j1gfO1HFq50kU3qawYxa4NTbI/ASNVVCBTsKquy2HYwM9r7ZnzkiMe8IEObAJtUVGSrePCOxAK2haQ==
}
dependencies
:
'
@babel/runtime'
:
7.2
0.7
'
@babel/runtime'
:
7.2
2.6
'
@changesets/git'
:
2.0.0
'
@changesets/logger'
:
0.0.5
'
@changesets/parse'
:
0.3.16
...
...
@@ -1197,7 +1197,7 @@ packages:
/@changesets/write@0.2.3
:
resolution
:
{
integrity
:
sha512-Dbamr7AIMvslKnNYsLFafaVORx4H0pvCA2MHqgtNCySMe1blImEyAEOzDmcgKAkgz4+uwoLz7demIrX+JBr/Xw==
}
dependencies
:
'
@babel/runtime'
:
7.2
0.7
'
@babel/runtime'
:
7.2
2.6
'
@changesets/types'
:
5.2.1
fs-extra
:
7.0.1
human-id
:
1.0.2
...
...
@@ -2608,7 +2608,7 @@ packages:
/@manypkg/get-packages@1.1.3
:
resolution
:
{
integrity
:
sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==
}
dependencies
:
'
@babel/runtime'
:
7.2
0.7
'
@babel/runtime'
:
7.2
2.6
'
@changesets/types'
:
4.1.0
'
@manypkg/find-root'
:
1.1.0
fs-extra
:
8.1.0
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment