Commit 03759658 authored by Adrian Sutton's avatar Adrian Sutton Committed by GitHub

op-challenger: Introduce output root source abstraction (#9534)

* op-challenger: Add a wrapper to allow validating output roots are safe at a given L1 head

* op-challenger: Don't pass L1Head around so much.

* op-challenger: Rename and add tests.

* Update bindings.

* Update op-ufm go.sum

* Rollback op-ufm changes.

* op-challenger: Alphabet VM does not require checking for consistency with L1 data

* op-challenger: Add error handling test.
parent b9eb5157
......@@ -10,6 +10,7 @@ import (
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/alphabet"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/cannon"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/outputs"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/outputs/source"
faultTypes "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
keccakTypes "github.com/ethereum-optimism/optimism/op-challenger/game/keccak/types"
"github.com/ethereum-optimism/optimism/op-challenger/game/scheduler"
......@@ -34,7 +35,7 @@ func RegisterGameTypes(
logger log.Logger,
m metrics.Metricer,
cfg *config.Config,
rollupClient outputs.OutputRollupClient,
rollupClient source.OutputRollupClient,
txSender types.TxSender,
gameFactory *contracts.DisputeGameFactoryContract,
caller *batching.MultiCaller,
......@@ -49,13 +50,15 @@ func RegisterGameTypes(
l2Client = l2
closer = l2Client.Close
}
outputSourceCreator := source.NewOutputSourceCreator(logger, rollupClient)
if cfg.TraceTypeEnabled(config.TraceTypeCannon) {
if err := registerCannon(faultTypes.CannonGameType, registry, ctx, cl, logger, m, cfg, rollupClient, txSender, gameFactory, caller, l2Client); err != nil {
if err := registerCannon(faultTypes.CannonGameType, registry, ctx, cl, logger, m, cfg, outputSourceCreator, txSender, gameFactory, caller, l2Client); err != nil {
return nil, fmt.Errorf("failed to register cannon game type: %w", err)
}
}
if cfg.TraceTypeEnabled(config.TraceTypePermissioned) {
if err := registerCannon(faultTypes.PermissionedGameType, registry, ctx, cl, logger, m, cfg, rollupClient, txSender, gameFactory, caller, l2Client); err != nil {
if err := registerCannon(faultTypes.PermissionedGameType, registry, ctx, cl, logger, m, cfg, outputSourceCreator, txSender, gameFactory, caller, l2Client); err != nil {
return nil, fmt.Errorf("failed to register permissioned cannon game type: %w", err)
}
}
......@@ -73,7 +76,7 @@ func registerAlphabet(
cl faultTypes.ClockReader,
logger log.Logger,
m metrics.Metricer,
rollupClient outputs.OutputRollupClient,
rollupClient source.OutputRollupClient,
txSender types.TxSender,
gameFactory *contracts.DisputeGameFactoryContract,
caller *batching.MultiCaller,
......@@ -87,13 +90,14 @@ func registerAlphabet(
if err != nil {
return nil, err
}
prestateProvider := outputs.NewPrestateProvider(rollupClient, prestateBlock)
splitDepth, err := contract.GetSplitDepth(ctx)
if err != nil {
return nil, err
}
outputSource := source.NewUnrestrictedOutputSource(rollupClient)
prestateProvider := outputs.NewPrestateProvider(outputSource, prestateBlock)
creator := func(ctx context.Context, logger log.Logger, gameDepth faultTypes.Depth, dir string) (faultTypes.TraceAccessor, error) {
accessor, err := outputs.NewOutputAlphabetTraceAccessor(logger, m, prestateProvider, rollupClient, splitDepth, prestateBlock, poststateBlock)
accessor, err := outputs.NewOutputAlphabetTraceAccessor(logger, m, prestateProvider, outputSource, splitDepth, prestateBlock, poststateBlock)
if err != nil {
return nil, err
}
......@@ -140,7 +144,7 @@ func registerCannon(
logger log.Logger,
m metrics.Metricer,
cfg *config.Config,
rollupClient outputs.OutputRollupClient,
outputSourceCreator *source.OutputSourceCreator,
txSender types.TxSender,
gameFactory *contracts.DisputeGameFactoryContract,
caller *batching.MultiCaller,
......@@ -155,12 +159,20 @@ func registerCannon(
if err != nil {
return nil, err
}
splitDepth, err := contract.GetSplitDepth(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load split depth: %w", err)
}
l1Head, err := contract.GetL1Head(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load L1 head: %w", err)
}
rollupClient, err := outputSourceCreator.ForL1Head(ctx, l1Head)
if err != nil {
return nil, fmt.Errorf("failed to create output root source: %w", err)
}
prestateProvider := outputs.NewPrestateProvider(rollupClient, prestateBlock)
creator := func(ctx context.Context, logger log.Logger, gameDepth faultTypes.Depth, dir string) (faultTypes.TraceAccessor, error) {
splitDepth, err := contract.GetSplitDepth(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load split depth: %w", err)
}
accessor, err := outputs.NewOutputCannonTraceAccessor(logger, m, cfg, l2Client, contract, prestateProvider, rollupClient, dir, splitDepth, prestateBlock, poststateBlock)
if err != nil {
return nil, err
......
......@@ -17,7 +17,7 @@ func NewOutputAlphabetTraceAccessor(
logger log.Logger,
m metrics.Metricer,
prestateProvider types.PrestateProvider,
rollupClient OutputRollupClient,
rollupClient OutputRootProvider,
splitDepth types.Depth,
prestateBlock uint64,
poststateBlock uint64,
......
......@@ -23,7 +23,7 @@ func NewOutputCannonTraceAccessor(
l2Client cannon.L2HeaderSource,
contract cannon.L1HeadSource,
prestateProvider types.PrestateProvider,
rollupClient OutputRollupClient,
rollupClient OutputRootProvider,
dir string,
splitDepth types.Depth,
prestateBlock uint64,
......
......@@ -5,7 +5,6 @@ import (
"fmt"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum/go-ethereum/common"
)
......@@ -13,10 +12,10 @@ var _ types.PrestateProvider = (*OutputPrestateProvider)(nil)
type OutputPrestateProvider struct {
prestateBlock uint64
rollupClient OutputRollupClient
rollupClient OutputRootProvider
}
func NewPrestateProvider(rollupClient OutputRollupClient, prestateBlock uint64) *OutputPrestateProvider {
func NewPrestateProvider(rollupClient OutputRootProvider, prestateBlock uint64) *OutputPrestateProvider {
return &OutputPrestateProvider{
prestateBlock: prestateBlock,
rollupClient: rollupClient,
......@@ -28,9 +27,9 @@ func (o *OutputPrestateProvider) AbsolutePreStateCommitment(ctx context.Context)
}
func (o *OutputPrestateProvider) outputAtBlock(ctx context.Context, block uint64) (common.Hash, error) {
output, err := o.rollupClient.OutputAtBlock(ctx, block)
root, err := o.rollupClient.OutputAtBlock(ctx, block)
if err != nil {
return common.Hash{}, fmt.Errorf("failed to fetch output at block %v: %w", block, err)
}
return common.Hash(output.OutputRoot), nil
return root, nil
}
......@@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/outputs/source"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/stretchr/testify/require"
)
......@@ -23,7 +24,7 @@ func newOutputPrestateProvider(t *testing.T, prestateBlock uint64) (*OutputPrest
},
}
return &OutputPrestateProvider{
rollupClient: &rollupClient,
rollupClient: source.NewUnrestrictedOutputSource(&rollupClient),
prestateBlock: prestateBlock,
}, &rollupClient
}
......
......@@ -6,8 +6,6 @@ import (
"fmt"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
)
......@@ -19,8 +17,8 @@ var (
var _ types.TraceProvider = (*OutputTraceProvider)(nil)
type OutputRollupClient interface {
OutputAtBlock(ctx context.Context, blockNum uint64) (*eth.OutputResponse, error)
type OutputRootProvider interface {
OutputAtBlock(ctx context.Context, blockNum uint64) (common.Hash, error)
}
// OutputTraceProvider is a [types.TraceProvider] implementation that uses
......@@ -28,17 +26,17 @@ type OutputRollupClient interface {
type OutputTraceProvider struct {
types.PrestateProvider
logger log.Logger
rollupClient OutputRollupClient
rollupProvider OutputRootProvider
prestateBlock uint64
poststateBlock uint64
gameDepth types.Depth
}
func NewTraceProviderFromInputs(logger log.Logger, prestateProvider types.PrestateProvider, rollupClient OutputRollupClient, gameDepth types.Depth, prestateBlock, poststateBlock uint64) *OutputTraceProvider {
func NewTraceProviderFromInputs(logger log.Logger, prestateProvider types.PrestateProvider, rollupProvider OutputRootProvider, gameDepth types.Depth, prestateBlock, poststateBlock uint64) *OutputTraceProvider {
return &OutputTraceProvider{
PrestateProvider: prestateProvider,
logger: logger,
rollupClient: rollupClient,
rollupProvider: rollupProvider,
prestateBlock: prestateBlock,
poststateBlock: poststateBlock,
gameDepth: gameDepth,
......@@ -71,9 +69,9 @@ func (o *OutputTraceProvider) GetStepData(_ context.Context, _ types.Position) (
}
func (o *OutputTraceProvider) outputAtBlock(ctx context.Context, block uint64) (common.Hash, error) {
output, err := o.rollupClient.OutputAtBlock(ctx, block)
root, err := o.rollupProvider.OutputAtBlock(ctx, block)
if err != nil {
return common.Hash{}, fmt.Errorf("failed to fetch output at block %v: %w", block, err)
}
return common.Hash(output.OutputRoot), nil
return root, err
}
......@@ -7,6 +7,7 @@ import (
"math/big"
"testing"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/outputs/source"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum-optimism/optimism/op-service/testlog"
......@@ -122,7 +123,7 @@ func setupWithTestData(t *testing.T, prestateBlock, poststateBlock uint64, custo
}
return &OutputTraceProvider{
logger: testlog.Logger(t, log.LevelInfo),
rollupClient: &rollupClient,
rollupProvider: source.NewUnrestrictedOutputSource(&rollupClient),
prestateBlock: prestateBlock,
poststateBlock: poststateBlock,
gameDepth: inputGameDepth,
......
package source
import (
"context"
"math"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
)
type OutputRollupClient interface {
OutputAtBlock(ctx context.Context, blockNum uint64) (*eth.OutputResponse, error)
}
type OutputSourceCreator struct {
log log.Logger
rollupClient OutputRollupClient
}
func NewOutputSourceCreator(logger log.Logger, rollupClient OutputRollupClient) *OutputSourceCreator {
return &OutputSourceCreator{
log: logger,
rollupClient: rollupClient,
}
}
func (l *OutputSourceCreator) ForL1Head(ctx context.Context, l1Head common.Hash) (*RestrictedOutputSource, error) {
// TODO(client-pod#416): Run op-program to detect the latest safe head supported by l1Head
return NewRestrictedOutputSource(l.rollupClient, math.MaxUint64), nil
}
package source
import (
"context"
"errors"
"fmt"
"github.com/ethereum/go-ethereum/common"
)
var ErrExceedsL1Head = errors.New("output root beyond safe head for L1 head")
type RestrictedOutputSource struct {
unrestricted *UnrestrictedOutputSource
maxSafeHead uint64
}
func NewRestrictedOutputSource(rollupClient OutputRollupClient, maxSafeHead uint64) *RestrictedOutputSource {
return &RestrictedOutputSource{
unrestricted: NewUnrestrictedOutputSource(rollupClient),
maxSafeHead: maxSafeHead,
}
}
func (l *RestrictedOutputSource) OutputAtBlock(ctx context.Context, blockNum uint64) (common.Hash, error) {
if blockNum > l.maxSafeHead {
return common.Hash{}, fmt.Errorf("%w, requested: %v max: %v", ErrExceedsL1Head, blockNum, l.maxSafeHead)
}
return l.unrestricted.OutputAtBlock(ctx, blockNum)
}
package source
import (
"context"
"errors"
"testing"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"
)
func TestRestrictedOutputLoader(t *testing.T) {
tests := []struct {
name string
maxSafeHead uint64
blockNum uint64
expectedErr error
}{
{
name: "GenesisNotRestricted",
maxSafeHead: 1000,
blockNum: 0,
expectedErr: nil,
},
{
name: "BothAtGenesis",
maxSafeHead: 0,
blockNum: 0,
expectedErr: nil,
},
{
name: "RestrictedToGenesis",
maxSafeHead: 0,
blockNum: 1,
expectedErr: ErrExceedsL1Head,
},
{
name: "JustBelowMaxHead",
maxSafeHead: 1000,
blockNum: 999,
expectedErr: nil,
},
{
name: "EqualMaxHead",
maxSafeHead: 1000,
blockNum: 1000,
expectedErr: nil,
},
{
name: "JustAboveMaxHead",
maxSafeHead: 1000,
blockNum: 1001,
expectedErr: ErrExceedsL1Head,
},
{
name: "WellAboveMaxHead",
maxSafeHead: 1000,
blockNum: 99001,
expectedErr: ErrExceedsL1Head,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
loader := NewRestrictedOutputSource(&stubOutputRollupClient{}, test.maxSafeHead)
result, err := loader.OutputAtBlock(context.Background(), test.blockNum)
if test.expectedErr == nil {
require.NoError(t, err)
require.Equal(t, common.Hash{byte(test.blockNum)}, result)
} else {
require.ErrorIs(t, err, test.expectedErr)
}
})
}
}
func TestRestrictedOutputLoader_ReturnsError(t *testing.T) {
expectedErr := errors.New("boom")
loader := NewRestrictedOutputSource(&stubOutputRollupClient{err: expectedErr}, 6)
_, err := loader.OutputAtBlock(context.Background(), 4)
require.ErrorIs(t, err, expectedErr)
}
type stubOutputRollupClient struct {
err error
}
func (s *stubOutputRollupClient) OutputAtBlock(_ context.Context, blockNum uint64) (*eth.OutputResponse, error) {
if s.err != nil {
return nil, s.err
}
return &eth.OutputResponse{
OutputRoot: eth.Bytes32{byte(blockNum)},
}, nil
}
package source
import (
"context"
"fmt"
"github.com/ethereum/go-ethereum/common"
)
type UnrestrictedOutputSource struct {
rollupClient OutputRollupClient
}
func NewUnrestrictedOutputSource(rollupClient OutputRollupClient) *UnrestrictedOutputSource {
return &UnrestrictedOutputSource{rollupClient: rollupClient}
}
func (l *UnrestrictedOutputSource) OutputAtBlock(ctx context.Context, blockNum uint64) (common.Hash, error) {
output, err := l.rollupClient.OutputAtBlock(ctx, blockNum)
if err != nil {
return common.Hash{}, fmt.Errorf("failed to fetch output at block %v: %w", blockNum, err)
}
return common.Hash(output.OutputRoot), nil
}
......@@ -8,6 +8,7 @@ import (
"testing"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/outputs/source"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/split"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum-optimism/optimism/op-service/eth"
......@@ -134,7 +135,7 @@ func setupAdapterTest(t *testing.T, topDepth types.Depth) (split.ProviderCreator
prestateProvider := &stubPrestateProvider{
absolutePrestate: prestateOutputRoot,
}
topProvider := NewTraceProviderFromInputs(testlog.Logger(t, log.LevelInfo), prestateProvider, rollupClient, topDepth, prestateBlock, poststateBlock)
topProvider := NewTraceProviderFromInputs(testlog.Logger(t, log.LevelInfo), prestateProvider, source.NewUnrestrictedOutputSource(rollupClient), topDepth, prestateBlock, poststateBlock)
adapter := OutputRootSplitAdapter(topProvider, creator.Create)
return adapter, creator
}
......
......@@ -12,6 +12,7 @@ import (
"github.com/ethereum-optimism/optimism/op-chain-ops/deployer"
"github.com/ethereum-optimism/optimism/op-chain-ops/genesis"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/outputs"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/outputs/source"
faultTypes "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum-optimism/optimism/op-e2e/e2eutils/challenger"
"github.com/ethereum-optimism/optimism/op-e2e/e2eutils/disputegame/preimage"
......@@ -154,8 +155,9 @@ func (h *FactoryHelper) StartOutputCannonGame(ctx context.Context, l2Node string
h.require.NoError(err, "Failed to load l2 block number")
splitDepth, err := game.SplitDepth(&bind.CallOpts{Context: ctx})
h.require.NoError(err, "Failed to load split depth")
prestateProvider := outputs.NewPrestateProvider(rollupClient, prestateBlock.Uint64())
provider := outputs.NewTraceProviderFromInputs(logger, prestateProvider, rollupClient, faultTypes.Depth(splitDepth.Uint64()), prestateBlock.Uint64(), poststateBlock.Uint64())
outputRootProvider := source.NewUnrestrictedOutputSource(rollupClient)
prestateProvider := outputs.NewPrestateProvider(outputRootProvider, prestateBlock.Uint64())
provider := outputs.NewTraceProviderFromInputs(logger, prestateProvider, outputRootProvider, faultTypes.Depth(splitDepth.Uint64()), prestateBlock.Uint64(), poststateBlock.Uint64())
return &OutputCannonGameHelper{
OutputGameHelper: OutputGameHelper{
......@@ -206,8 +208,9 @@ func (h *FactoryHelper) StartOutputAlphabetGame(ctx context.Context, l2Node stri
h.require.NoError(err, "Failed to load l2 block number")
splitDepth, err := game.SplitDepth(&bind.CallOpts{Context: ctx})
h.require.NoError(err, "Failed to load split depth")
prestateProvider := outputs.NewPrestateProvider(rollupClient, prestateBlock.Uint64())
provider := outputs.NewTraceProviderFromInputs(logger, prestateProvider, rollupClient, faultTypes.Depth(splitDepth.Uint64()), prestateBlock.Uint64(), poststateBlock.Uint64())
outputRootProvider := source.NewUnrestrictedOutputSource(rollupClient)
prestateProvider := outputs.NewPrestateProvider(outputRootProvider, prestateBlock.Uint64())
provider := outputs.NewTraceProviderFromInputs(logger, prestateProvider, outputRootProvider, faultTypes.Depth(splitDepth.Uint64()), prestateBlock.Uint64(), poststateBlock.Uint64())
return &OutputAlphabetGameHelper{
OutputGameHelper: OutputGameHelper{
......
......@@ -5,6 +5,7 @@ import (
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/outputs"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/outputs/source"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
"github.com/ethereum-optimism/optimism/op-e2e/e2eutils/challenger"
"github.com/ethereum-optimism/optimism/op-service/sources/batching"
......@@ -44,8 +45,9 @@ func (g *OutputAlphabetGameHelper) CreateHonestActor(ctx context.Context, l2Node
g.require.NoError(err, "Get block range")
splitDepth := g.SplitDepth(ctx)
rollupClient := g.system.RollupClient(l2Node)
prestateProvider := outputs.NewPrestateProvider(rollupClient, prestateBlock)
correctTrace, err := outputs.NewOutputAlphabetTraceAccessor(logger, metrics.NoopMetrics, prestateProvider, rollupClient, splitDepth, prestateBlock, poststateBlock)
outputRootProvider := source.NewUnrestrictedOutputSource(rollupClient)
prestateProvider := outputs.NewPrestateProvider(outputRootProvider, prestateBlock)
correctTrace, err := outputs.NewOutputAlphabetTraceAccessor(logger, metrics.NoopMetrics, prestateProvider, outputRootProvider, splitDepth, prestateBlock, poststateBlock)
g.require.NoError(err, "Create trace accessor")
return &OutputHonestHelper{
t: g.t,
......
......@@ -11,6 +11,7 @@ import (
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/cannon"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/outputs"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/outputs/source"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/split"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
......@@ -63,9 +64,10 @@ func (g *OutputCannonGameHelper) CreateHonestActor(ctx context.Context, l2Node s
dir := filepath.Join(cfg.Datadir, "honest")
splitDepth := g.SplitDepth(ctx)
rollupClient := g.system.RollupClient(l2Node)
prestateProvider := outputs.NewPrestateProvider(rollupClient, prestateBlock)
outputRootProvider := source.NewUnrestrictedOutputSource(rollupClient)
prestateProvider := outputs.NewPrestateProvider(outputRootProvider, prestateBlock)
accessor, err := outputs.NewOutputCannonTraceAccessor(
logger, metrics.NoopMetrics, cfg, l2Client, contract, prestateProvider, rollupClient, dir, splitDepth, prestateBlock, poststateBlock)
logger, metrics.NoopMetrics, cfg, l2Client, contract, prestateProvider, outputRootProvider, dir, splitDepth, prestateBlock, poststateBlock)
g.require.NoError(err, "Failed to create output cannon trace accessor")
return &OutputHonestHelper{
t: g.t,
......@@ -229,8 +231,9 @@ func (g *OutputCannonGameHelper) createCannonTraceProvider(ctx context.Context,
prestateBlock, poststateBlock, err := contract.GetBlockRange(ctx)
g.require.NoError(err, "Failed to load block range")
rollupClient := g.system.RollupClient(l2Node)
prestateProvider := outputs.NewPrestateProvider(rollupClient, prestateBlock)
outputProvider := outputs.NewTraceProviderFromInputs(logger, prestateProvider, rollupClient, splitDepth, prestateBlock, poststateBlock)
outputRootProvider := source.NewUnrestrictedOutputSource(rollupClient)
prestateProvider := outputs.NewPrestateProvider(outputRootProvider, prestateBlock)
outputProvider := outputs.NewTraceProviderFromInputs(logger, prestateProvider, outputRootProvider, splitDepth, prestateBlock, poststateBlock)
selector := split.NewSplitProviderSelector(outputProvider, splitDepth, func(ctx context.Context, depth types.Depth, pre types.Claim, post types.Claim) (types.TraceProvider, error) {
agreed, disputed, err := outputs.FetchProposals(ctx, outputProvider, pre, post)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment