Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Merged by Bors] - Support poet certificates #5221

Closed
wants to merge 71 commits into from
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
d16cbe7
Support for poet certificates WIP
poszu Nov 1, 2023
08677b3
Support for poet certificates cont
poszu Nov 3, 2023
d68a562
Initial certification using existing ATX or initial POST
poszu Nov 6, 2023
535dfcf
Improve poet HTTP client coverage
poszu Nov 6, 2023
513c29a
Bump certifier
poszu Nov 6, 2023
2bf4807
Use the right post challenge to certify
poszu Nov 8, 2023
54f915b
Fixed POST metadata sent to certifier
poszu Nov 9, 2023
6cc3971
Bump certifier image
poszu Nov 9, 2023
f79232c
Migrate certifier store to localdb
poszu Nov 9, 2023
a2893dc
Remove CertifierInfo struct
poszu Nov 9, 2023
ffd77d0
Add systest for poet registrations with PoW and cert
poszu Nov 10, 2023
326bde0
Configurable certifier retry options
poszu Nov 13, 2023
f2ec6f7
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Nov 13, 2023
e0a6f89
Increase systest CI job timeout
poszu Nov 13, 2023
0054df7
Keep POST proof in localDB for certification
poszu Nov 14, 2023
50fa30f
Take optional poet certificates in config
poszu Nov 14, 2023
2a9210e
Bump poet to v0.10.0
poszu Nov 15, 2023
111576b
Apply suggestions from code review
poszu Nov 20, 2023
678e2c6
update nipost_test.go
poszu Nov 20, 2023
29118cd
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Nov 20, 2023
ca0fcf0
fixup ProofToCertifyMetadata struct name
poszu Nov 20, 2023
f80eb4a
Run poet systests in parallel
poszu Nov 20, 2023
4302d65
Add NodeID to certs passed in config
poszu Nov 20, 2023
cb33ecf
Fix certifier config in systests
poszu Nov 20, 2023
604bd51
Rename NewCertifierOption
poszu Nov 21, 2023
0a86126
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Nov 21, 2023
ce012ca
Apply localdb code migrations on checkpoint recovery
poszu Nov 21, 2023
97df610
Remove importing certificates
poszu Nov 22, 2023
18e8f23
Build initial post if post cannot be found
poszu Nov 23, 2023
98defe3
Remove sourcegraph/conc dep
poszu Nov 23, 2023
4eaa867
Bump certifier-service in systests to v0.6.0
poszu Nov 23, 2023
4270563
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Nov 24, 2023
c1ccfa8
Don't update post in localdb
poszu Nov 24, 2023
b37a705
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Nov 27, 2023
8439433
Fix UT
poszu Nov 27, 2023
5cca828
Autoscale post verifying workers (#5354)
poszu Dec 27, 2023
00edbc3
Fix default value of challenge in initial_post table
poszu Dec 28, 2023
c61e32d
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Jan 5, 2024
9b9f881
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Jan 9, 2024
e81a4c1
Fix flaky TestNIPostBuilder_Close
poszu Jan 9, 2024
7e3961b
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Feb 2, 2024
210c3e1
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Feb 12, 2024
c053552
Fixes
poszu Feb 12, 2024
7319d84
Bump post and certifier services in systests
poszu Feb 12, 2024
314e921
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Mar 21, 2024
97f0ff1
Support for poet certs with expiration
poszu Mar 21, 2024
7e35d3f
Use cert verification shared from poet
poszu Mar 25, 2024
59f45a0
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Mar 25, 2024
bd3130d
Fix linting issues
poszu Mar 25, 2024
87c9284
Bump certifier service in systests
poszu Mar 25, 2024
f347e3d
Rename local sql migration
poszu Mar 25, 2024
377a5d5
Fix staticcheck
poszu Mar 25, 2024
342eee9
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Apr 18, 2024
459f453
Use nipost.Post
poszu Apr 18, 2024
601df9d
More review fixes
poszu Apr 18, 2024
af1e89a
Update poet and post-rs
poszu Apr 18, 2024
b7426fa
update test
poszu Apr 19, 2024
d72d416
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Apr 23, 2024
af29624
Fix UTs
poszu Apr 23, 2024
0ec2d97
Refactor certifier to lookup PoST on its own
poszu Apr 23, 2024
845c12d
Fix systest
poszu Apr 23, 2024
30e322f
Merge remote-tracking branch 'origin/develop' into support-poet-certi…
poszu Apr 26, 2024
c0b306c
Review feedback
poszu Apr 26, 2024
84c1800
Optimize searching for positioning ATX (#5952)
poszu May 22, 2024
f4c358d
Move certification to the poet client
poszu May 22, 2024
d1d597a
Extend E2E activation test to use poet certificates with expiration
poszu May 22, 2024
377c62d
Parallelize certifying initial post
poszu May 22, 2024
62ea324
Avoid redundant certifications for (nodeID, pubkey) pairs
poszu May 22, 2024
879db03
Review feedback
poszu May 23, 2024
c9f3f96
Fix remaining UTs
poszu May 23, 2024
7f83cab
Fix remaining UTs
poszu May 23, 2024
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 Makefile-libs.Inc
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ else
endif
endif

POSTRS_SETUP_REV = 0.7.7
POSTRS_SETUP_REV = 0.7.8
POSTRS_SETUP_ZIP = libpost-$(platform)-v$(POSTRS_SETUP_REV).zip
POSTRS_SETUP_URL_ZIP ?= https://github.com/spacemeshos/post-rs/releases/download/v$(POSTRS_SETUP_REV)/$(POSTRS_SETUP_ZIP)

Expand Down
22 changes: 13 additions & 9 deletions activation/activation.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"github.com/spacemeshos/go-spacemesh/sql/localsql/nipost"
)

var ErrNotFound = errors.New("not found")

// PoetConfig is the configuration to interact with the poet server.
type PoetConfig struct {
PhaseShift time.Duration `mapstructure:"phase-shift"`
Expand Down Expand Up @@ -77,7 +79,6 @@
localDB *localsql.Database
publisher pubsub.Publisher
nipostBuilder nipostBuilder
certifier certifierService
validator nipostValidator
layerClock layerClock
syncer syncer
Expand Down Expand Up @@ -158,12 +159,6 @@
}
}

func WithPoetCertifier(c certifierService) BuilderOption {
return func(b *Builder) {
b.certifier = c
}
}

// NewBuilder returns an atx builder that will start a routine that will attempt to create an atx upon each new layer.
func NewBuilder(
conf Config,
Expand All @@ -186,7 +181,6 @@
localDB: localDB,
publisher: publisher,
nipostBuilder: nipostBuilder,
certifier: &disabledCertifier{},
layerClock: layerClock,
syncer: syncer,
log: log,
Expand Down Expand Up @@ -377,7 +371,7 @@
}, postInfo.NumUnits)
if err != nil {
b.log.Error("initial POST is invalid", log.ZShortStringer("smesherID", nodeID), zap.Error(err))
if err := nipost.RemovePost(b.localDB, nodeID); err != nil {

Check warning on line 374 in activation/activation.go

View check run for this annotation

Codecov / codecov/patch

activation/activation.go#L374

Added line #L374 was not covered by tests
b.log.Fatal("failed to remove initial post", log.ZShortStringer("smesherID", nodeID), zap.Error(err))
}
return fmt.Errorf("initial POST is invalid: %w", err)
Expand Down Expand Up @@ -406,7 +400,17 @@
case <-b.layerClock.AwaitLayer(currentLayer.Add(1)):
}
}
b.certifier.CertifyAll(ctx, sig.NodeID(), b.poets)
var eg errgroup.Group
for _, poet := range b.poets {
eg.Go(func() error {
_, err := poet.Certify(ctx, sig.NodeID())
if err != nil {
b.log.Warn("failed to certify poet", zap.Error(err), log.ZShortStringer("smesherID", sig.NodeID()))
}
return nil
})
}
eg.Wait()

for {
err := b.PublishActivationTx(ctx, sig)
Expand Down Expand Up @@ -548,7 +552,7 @@
}, post.NumUnits)
if err != nil {
logger.Error("initial POST is invalid", zap.Error(err))
if err := nipost.RemovePost(b.localDB, nodeID); err != nil {

Check warning on line 555 in activation/activation.go

View check run for this annotation

Codecov / codecov/patch

activation/activation.go#L555

Added line #L555 was not covered by tests
logger.Fatal("failed to remove initial post", zap.Error(err))
}
return nil, fmt.Errorf("initial POST is invalid: %w", err)
Expand Down
201 changes: 62 additions & 139 deletions activation/certifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,20 @@
"io"
"net/http"
"net/url"
"sync"
"time"

"github.com/hashicorp/go-retryablehttp"
"github.com/spacemeshos/poet/shared"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"

"github.com/spacemeshos/go-spacemesh/activation/wire"
"github.com/spacemeshos/go-spacemesh/codec"
"github.com/spacemeshos/go-spacemesh/common/types"
"github.com/spacemeshos/go-spacemesh/sql"
"github.com/spacemeshos/go-spacemesh/sql/atxs"
"github.com/spacemeshos/go-spacemesh/sql/localsql"
"github.com/spacemeshos/go-spacemesh/sql/localsql/certifier"
certifierdb "github.com/spacemeshos/go-spacemesh/sql/localsql/certifier"
"github.com/spacemeshos/go-spacemesh/sql/localsql/nipost"
)

Expand Down Expand Up @@ -82,6 +82,9 @@
logger *zap.Logger
db *localsql.Database
client certifierClient

certificationsLock sync.Mutex
certifications map[string]chan struct{}
}

func NewCertifier(
Expand All @@ -90,139 +93,67 @@
client certifierClient,
fasmat marked this conversation as resolved.
Show resolved Hide resolved
) *Certifier {
c := &Certifier{
client: client,
logger: logger,
db: db,
client: client,
logger: logger,
db: db,
certifications: make(map[string]chan struct{}),
}

return c
}

func (c *Certifier) Certificate(id types.NodeID, poet string) *certifier.PoetCert {
cert, err := certifier.Certificate(c.db, id, poet)
func (c *Certifier) Certificate(
ctx context.Context,
id types.NodeID,
certifier *url.URL,
pubkey []byte,
) (*certifierdb.PoetCert, error) {
// We index certs in DB by node ID and pubkey. To avoid redundant queries, we allow only 1
// request per (nodeID, pubkey) pair to be in flight at a time.
key := string(append(id.Bytes(), pubkey...))
c.certificationsLock.Lock()
if ch, ok := c.certifications[key]; ok {
c.certificationsLock.Unlock()
<-ch
} else {
ch := make(chan struct{})
c.certifications[key] = ch
c.certificationsLock.Unlock()
defer func() {
c.certificationsLock.Lock()
close(ch)
delete(c.certifications, key)
c.certificationsLock.Unlock()
}()
}
poszu marked this conversation as resolved.
Show resolved Hide resolved

cert, err := certifierdb.Certificate(c.db, id, pubkey)
switch {
case err == nil:
return cert
return cert, nil
case !errors.Is(err, sql.ErrNotFound):
c.logger.Warn("failed to get certificate", zap.Error(err))
return nil, fmt.Errorf("getting certificate from DB for: %w", err)

Check warning on line 135 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L134-L135

Added lines #L134 - L135 were not covered by tests
}
return nil
return c.Recertify(ctx, id, certifier, pubkey)
}

func (c *Certifier) Recertify(ctx context.Context, id types.NodeID, poet PoetClient) (*certifier.PoetCert, error) {
url, pubkey, err := poet.CertifierInfo(ctx)
if err != nil {
return nil, fmt.Errorf("querying certifier info: %w", err)
}
cert, err := c.client.Certify(ctx, id, url, pubkey)
func (c *Certifier) Recertify(
ctx context.Context,
id types.NodeID,
certifier *url.URL,
pubkey []byte,
) (*certifierdb.PoetCert, error) {
cert, err := c.client.Certificate(ctx, id, certifier, pubkey)
if err != nil {
return nil, fmt.Errorf("certifying POST for %s at %v: %w", poet.Address(), url, err)
return nil, fmt.Errorf("certifying POST at %v: %w", certifier, err)

Check warning on line 148 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L148

Added line #L148 was not covered by tests
}

if err := certifier.AddCertificate(c.db, id, *cert, poet.Address()); err != nil {
if err := certifierdb.AddCertificate(c.db, id, *cert, pubkey); err != nil {
c.logger.Warn("failed to persist poet cert", zap.Error(err))

Check warning on line 152 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L152

Added line #L152 was not covered by tests
}

return cert, nil
}

// CertifyAll certifies the nodeID for all poets that require a certificate.
// It optimizes the number of certification requests by taking a unique set of
// certifiers among the given poets and sending a single request to each of them.
// It returns a map of a poet address to a certificate for it.
func (c *Certifier) CertifyAll(
ctx context.Context,
id types.NodeID,
poets []PoetClient,
) map[string]*certifier.PoetCert {
certs := make(map[string]*certifier.PoetCert)
poetsToCertify := []PoetClient{}
for _, poet := range poets {
if cert := c.Certificate(id, poet.Address()); cert != nil {
certs[poet.Address()] = cert
} else {
poetsToCertify = append(poetsToCertify, poet)
}
}
if len(poetsToCertify) == 0 {
return certs
}

type certInfo struct {
url *url.URL
pubkey []byte
poet string
}

certifierInfos := make([]*certInfo, len(poetsToCertify))
var eg errgroup.Group
for i, poet := range poetsToCertify {
eg.Go(func() error {
url, pubkey, err := poet.CertifierInfo(ctx)
if err != nil {
c.logger.Warn("failed to query for certifier info", zap.Error(err), zap.String("poet", poet.Address()))
return nil
}
certifierInfos[i] = &certInfo{
url: url,
pubkey: pubkey,
poet: poet.Address(),
}
return nil
})
}
eg.Wait()

type certService struct {
url *url.URL
pubkey []byte
poets []string
}
certSvcs := make(map[string]*certService)
for _, info := range certifierInfos {
if info == nil {
continue
}

if svc, ok := certSvcs[string(info.pubkey)]; !ok {
certSvcs[string(info.pubkey)] = &certService{
url: info.url,
pubkey: info.pubkey,
poets: []string{info.poet},
}
} else {
svc.poets = append(svc.poets, info.poet)
}
}

for _, svc := range certSvcs {
c.logger.Info(
"certifying for poets",
zap.Stringer("certifier", svc.url),
zap.Strings("poets", svc.poets),
)

cert, err := c.client.Certify(ctx, id, svc.url, svc.pubkey)
if err != nil {
c.logger.Warn("failed to certify", zap.Error(err), zap.Stringer("certifier", svc.url))
continue
}
c.logger.Info(
"successfully obtained certificate",
zap.Stringer("certifier", svc.url),
zap.Binary("cert data", cert.Data),
zap.Binary("cert signature", cert.Signature),
)
for _, poet := range svc.poets {
if err := certifier.AddCertificate(c.db, id, *cert, poet); err != nil {
c.logger.Warn("failed to persist poet cert", zap.Error(err))
}
certs[poet] = cert
}
}
return certs
}

type CertifierClient struct {
client *retryablehttp.Client
logger *zap.Logger
Expand Down Expand Up @@ -275,17 +206,17 @@
}
atx, err := atxs.Get(c.db, atxid)
if err != nil {
return nil, fmt.Errorf("failed to retrieve ATX: %w", err)

Check warning on line 209 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L209

Added line #L209 was not covered by tests
}
atxNipost, err := loadNipost(ctx, c.db, atxid)
if err != nil {
return nil, errors.New("no NIPoST found in last ATX")

Check warning on line 213 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L213

Added line #L213 was not covered by tests
}
if atx.CommitmentATX == nil {
if commitmentAtx, err := atxs.CommitmentATX(c.db, nodeId); err != nil {
return nil, fmt.Errorf("failed to retrieve commitment ATX: %w", err)
} else {
atx.CommitmentATX = &commitmentAtx

Check warning on line 219 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L216-L219

Added lines #L216 - L219 were not covered by tests
}
}

Expand All @@ -310,15 +241,15 @@
return post, nil
case errors.Is(err, sql.ErrNotFound):
// no post found
default:
return nil, fmt.Errorf("loading initial post from db: %w", err)

Check warning on line 245 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L244-L245

Added lines #L244 - L245 were not covered by tests
}

c.logger.Info("POST not found in local DB. Trying to obtain POST from an existing ATX")
if post, err := c.obtainPostFromLastAtx(ctx, id); err == nil {
c.logger.Info("found POST in an existing ATX")
if err := nipost.AddPost(c.localDb, id, *post); err != nil {
c.logger.Error("failed to save post", zap.Error(err))

Check warning on line 252 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L252

Added line #L252 was not covered by tests
}
return post, nil
}
Expand All @@ -326,15 +257,15 @@
return nil, errors.New("PoST not found")
}

func (c *CertifierClient) Certify(
func (c *CertifierClient) Certificate(
ctx context.Context,
id types.NodeID,
url *url.URL,
pubkey []byte,
) (*certifier.PoetCert, error) {
) (*certifierdb.PoetCert, error) {
post, err := c.obtainPost(ctx, id)
if err != nil {
return nil, fmt.Errorf("obtaining PoST: %w", err)

Check warning on line 268 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L268

Added line #L268 was not covered by tests
}

request := CertifyRequest{
Expand All @@ -353,35 +284,35 @@

jsonRequest, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("marshaling request: %w", err)

Check warning on line 287 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L287

Added line #L287 was not covered by tests
}

req, err := retryablehttp.NewRequestWithContext(ctx, "POST", url.JoinPath("/certify").String(), jsonRequest)
if err != nil {
return nil, fmt.Errorf("creating HTTP request: %w", err)

Check warning on line 292 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L292

Added line #L292 was not covered by tests
}
req.Header.Set("Content-Type", "application/json")

resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)

Check warning on line 298 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L298

Added line #L298 was not covered by tests
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
var body []byte
if resp.Body != nil {
body, _ = io.ReadAll(resp.Body)

Check warning on line 305 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L303-L305

Added lines #L303 - L305 were not covered by tests
}
return nil, fmt.Errorf("request failed with code %d (message: %s)", resp.StatusCode, body)

Check warning on line 307 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L307

Added line #L307 was not covered by tests
}

certResponse := CertifyResponse{}
if err := json.NewDecoder(resp.Body).Decode(&certResponse); err != nil {
return nil, fmt.Errorf("decoding JSON response: %w", err)

Check warning on line 312 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L312

Added line #L312 was not covered by tests
}
if !bytes.Equal(certResponse.PubKey, pubkey) {
return nil, errors.New("pubkey is invalid")

Check warning on line 315 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L315

Added line #L315 was not covered by tests
}

opaqueCert := &shared.OpaqueCert{
Expand All @@ -397,41 +328,33 @@
if cert.Expiration != nil {
c.logger.Info("certificate has expiration date", zap.Time("expiration", *cert.Expiration))
if time.Until(*cert.Expiration) < 0 {
return nil, errors.New("certificate is expired")

Check warning on line 331 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L331

Added line #L331 was not covered by tests
}
}

return &certifier.PoetCert{
return &certifierdb.PoetCert{
Data: opaqueCert.Data,
Signature: opaqueCert.Signature,
}, nil
}

type disabledCertifier struct{}

func (d *disabledCertifier) Certificate(types.NodeID, string) *certifier.PoetCert {
return nil
}

func (d *disabledCertifier) Recertify(context.Context, types.NodeID, PoetClient) (*certifier.PoetCert, error) {
return nil, errors.New("certifier disabled")
}

func (d *disabledCertifier) CertifyAll(context.Context, types.NodeID, []PoetClient) map[string]*certifier.PoetCert {
return nil
}

// load NIPoST for the given ATX from the database.
func loadNipost(ctx context.Context, db sql.Executor, id types.ATXID) (*types.NIPost, error) {
var blob sql.Blob
if err := atxs.LoadBlob(ctx, db, id.Bytes(), &blob); err != nil {
version, err := atxs.LoadBlob(ctx, db, id.Bytes(), &blob)
if err != nil {
return nil, fmt.Errorf("getting blob for %s: %w", id, err)

Check warning on line 346 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L346

Added line #L346 was not covered by tests
}

// TODO: decide about version based on the `version` column
var atx wire.ActivationTxV1
if err := codec.Decode(blob.Bytes, &atx); err != nil {
return nil, fmt.Errorf("decoding ATX blob: %w", err)
switch version {
case types.AtxV1:
var atx wire.ActivationTxV1
if err := codec.Decode(blob.Bytes, &atx); err != nil {
return nil, fmt.Errorf("decoding ATX blob: %w", err)

Check warning on line 353 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L353

Added line #L353 was not covered by tests
}
return wire.NiPostFromWireV1(atx.NIPost), nil
case types.AtxV2:

Check warning on line 356 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L356

Added line #L356 was not covered by tests
// TODO: support ATX V2
}
return wire.NiPostFromWireV1(atx.NIPost), nil
panic("unsupported ATX version")

Check warning on line 359 in activation/certifier.go

View check run for this annotation

Codecov / codecov/patch

activation/certifier.go#L359

Added line #L359 was not covered by tests
}
Loading
Loading