Repository navigation
Expand file tree
/
Copy pathconstruct_predecoded_test.go
More file actions
347 lines (310 loc) · 13.5 KB
/
Copy pathconstruct_predecoded_test.go
File metadata and controls
347 lines (310 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
package square_test
import (
"fmt"
"math/rand"
"testing"
square "github.com/celestiaorg/go-square/v4"
"github.com/celestiaorg/go-square/v4/internal/test"
"github.com/celestiaorg/go-square/v4/share"
"github.com/celestiaorg/go-square/v4/tx"
"github.com/stretchr/testify/require"
)
// classifiableTx is one entry of the corpus below: raw transaction bytes plus,
// for a pay-for-fibre transaction, the system blob a caller would synthesize.
type classifiableTx struct {
name string
raw []byte
// fibre is non-nil for a pay-for-fibre transaction. A fibre tx is never a
// blob tx, so the pre-decoded blob field never applies to one.
fibre *tx.FibreTx
}
// classifyAll classifies entries the way an application would. With preDecode
// set, a transaction that decodes as a blob tx carries its decoded form;
// otherwise ClassifiedTx.BlobTx is left nil and the square decodes the bytes
// itself. Everything else about the two results is identical by construction.
func classifyAll(entries []classifiableTx, preDecode bool) ([]square.ClassifiedTx, error) {
classified := make([]square.ClassifiedTx, len(entries))
for i, entry := range entries {
var err error
switch {
case entry.fibre != nil:
classified[i], err = square.NewClassifiedFibreTx(entry.fibre)
case preDecode:
decoded, isBlobTx, decodeErr := tx.UnmarshalBlobTx(entry.raw)
if isBlobTx && decodeErr == nil {
classified[i], err = square.NewClassifiedBlobTx(entry.raw, decoded)
} else {
classified[i] = square.NewClassifiedTx(entry.raw)
}
default:
classified[i] = square.NewClassifiedTx(entry.raw)
}
if err != nil {
return nil, fmt.Errorf("classifying %q at index %d: %w", entry.name, i, err)
}
}
return classified, nil
}
func deterministicBytes(rng *rand.Rand, size int) []byte {
b := make([]byte, size)
// (*rand.Rand).Read never returns an error.
_, _ = rng.Read(b)
return b
}
func deterministicBlobTx(t *testing.T, rng *rand.Rand, blobSizes ...int) []byte {
t.Helper()
blobs := make([]*share.Blob, len(blobSizes))
for i, size := range blobSizes {
blob, err := share.NewBlob(test.DefaultTestNamespace, deterministicBytes(rng, size), share.ShareVersionZero, nil)
require.NoError(t, err)
blobs[i] = blob
}
raw, err := tx.MarshalBlobTx(deterministicBytes(rng, 300), blobs...)
require.NoError(t, err)
return raw
}
// predecodedCorpus returns transactions reaching every branch the pre-decoded
// field touches: normal txs, blob txs that decode, blob txs that are
// recognized but rejected, pay-for-fibre txs, and bytes that are not a valid
// classification at all.
func predecodedCorpus(t *testing.T) []classifiableTx {
t.Helper()
rng := rand.New(rand.NewSource(11))
oneBlob := deterministicBlobTx(t, rng, 100)
nested, err := tx.MarshalBlobTx(oneBlob, mustBlob(t, rng, 100))
require.NoError(t, err)
fibreRaw := []byte("pay for fibre tx bytes")
fibreBlob, err := share.NewV2Blob(
share.MustNewV0Namespace([]byte("fibre")),
0,
make([]byte, share.FibreCommitmentSize),
make([]byte, share.SignerSize),
)
require.NoError(t, err)
corpus := []classifiableTx{
{name: "normal tx", raw: []byte("an ordinary transaction")},
{name: "normal tx, share spanning", raw: deterministicBytes(rng, 900)},
{name: "undecodable bytes", raw: []byte{0x00}},
{name: "empty bytes", raw: nil},
{name: "blob tx, one small blob", raw: oneBlob},
{name: "blob tx, three blobs", raw: deterministicBlobTx(t, rng, 100, 500, 1000)},
{name: "blob tx, multi-share blob", raw: deterministicBlobTx(t, rng, 2000)},
// Recognized as a blob tx but rejected by UnmarshalBlobTx. The caller
// cannot pre-decode these, so both sides take the decoding path and
// must report the same error.
{name: "non-canonical blob tx", raw: append(append([]byte(nil), oneBlob...), 0x78, 0x01)},
{name: "nested blob tx", raw: nested},
{name: "fibre tx", raw: fibreRaw, fibre: &tx.FibreTx{Tx: fibreRaw, SystemBlob: fibreBlob}},
}
return corpus
}
func mustBlob(t *testing.T, rng *rand.Rand, size int) *share.Blob {
t.Helper()
blob, err := share.NewBlob(test.DefaultTestNamespace, deterministicBytes(rng, size), share.ShareVersionZero, nil)
require.NoError(t, err)
return blob
}
// TestConstructIgnoresPreDecodedBlobTx is the property the ClassifiedTx.BlobTx
// field exists to preserve: handing Construct a blob tx the caller already
// decoded changes nothing it decides. For every generated list, the square
// bytes, the accept/reject verdict and the error text must match the same list
// classified without the field.
//
// This is a consensus property, not an optimization detail: both forms decide
// a proposal's data root, and ProcessProposal uses the pre-decoded one while
// other callers do not.
func TestConstructIgnoresPreDecodedBlobTx(t *testing.T) {
corpus := predecodedCorpus(t)
rng := rand.New(rand.NewSource(7))
for _, maxSquareSize := range []int{4, 8, 64, 128} {
for iteration := range 400 {
entries := make([]classifiableTx, 1+rng.Intn(7))
for i := range entries {
entries[i] = corpus[rng.Intn(len(corpus))]
}
names := make([]string, len(entries))
for i, entry := range entries {
names[i] = entry.name
}
where := fmt.Sprintf("maxSquareSize=%d iteration=%d txs=%v", maxSquareSize, iteration, names)
got, gotErr := classifyAll(entries, true)
want, wantErr := classifyAll(entries, false)
require.Equal(t, fmt.Sprint(wantErr), fmt.Sprint(gotErr), "classification disagreed: %s", where)
if wantErr != nil {
continue
}
require.Len(t, got, len(want))
gotSquare, gotErr := square.Construct(got, maxSquareSize, defaultSubtreeRootThreshold)
wantSquare, wantErr := square.Construct(want, maxSquareSize, defaultSubtreeRootThreshold)
require.Equal(t, fmt.Sprint(wantErr), fmt.Sprint(gotErr), "construction disagreed: %s", where)
if wantErr != nil {
continue
}
require.Equal(t, wantSquare, gotSquare, "squares differ: %s", where)
}
}
}
// TestShareRangesIgnorePreDecodedBlobTx covers the other two exported callers
// of populateBuilder, which report share positions rather than a square.
func TestShareRangesIgnorePreDecodedBlobTx(t *testing.T) {
rng := rand.New(rand.NewSource(13))
fibreRaw := []byte("pay for fibre tx bytes")
fibreBlob, err := share.NewV2Blob(
share.MustNewV0Namespace([]byte("fibre")),
0,
make([]byte, share.FibreCommitmentSize),
make([]byte, share.SignerSize),
)
require.NoError(t, err)
entries := []classifiableTx{
{name: "normal tx", raw: []byte("an ordinary transaction")},
{name: "blob tx, two blobs", raw: deterministicBlobTx(t, rng, 300, 1500)},
{name: "blob tx, one blob", raw: deterministicBlobTx(t, rng, 700)},
{name: "fibre tx", raw: fibreRaw, fibre: &tx.FibreTx{Tx: fibreRaw, SystemBlob: fibreBlob}},
}
got, err := classifyAll(entries, true)
require.NoError(t, err)
want, err := classifyAll(entries, false)
require.NoError(t, err)
for txIndex := range entries {
gotRange, gotErr := square.TxShareRange(got, txIndex, defaultMaxSquareSize, defaultSubtreeRootThreshold)
wantRange, wantErr := square.TxShareRange(want, txIndex, defaultMaxSquareSize, defaultSubtreeRootThreshold)
require.Equal(t, fmt.Sprint(wantErr), fmt.Sprint(gotErr), "TxShareRange at %d", txIndex)
require.Equal(t, wantRange, gotRange, "TxShareRange at %d", txIndex)
}
// blob share ranges for the two blob txs, by (tx index, blob index)
for _, position := range [][2]int{{1, 0}, {1, 1}, {2, 0}} {
txIndex, blobIndex := position[0], position[1]
gotRange, gotErr := square.BlobShareRange(got, txIndex, blobIndex, defaultMaxSquareSize, defaultSubtreeRootThreshold)
wantRange, wantErr := square.BlobShareRange(want, txIndex, blobIndex, defaultMaxSquareSize, defaultSubtreeRootThreshold)
require.Equal(t, fmt.Sprint(wantErr), fmt.Sprint(gotErr), "BlobShareRange at %d/%d", txIndex, blobIndex)
require.Equal(t, wantRange, gotRange, "BlobShareRange at %d/%d", txIndex, blobIndex)
}
}
// TestConstructTrustsPreDecodedBlobTx pins the field's contract from the other
// side: Construct uses the pre-decoded blob tx as given and does not decode
// Bytes to check it. A caller that sets a blob tx which is not the decode of
// Bytes gets a square built from what it supplied.
//
// This is the documented, caller-trusted behaviour - the same trust
// Builder.AppendBlobTx already takes on its argument. The test exists so that
// re-introducing a decode here, which would silently undo the optimization and
// change what a mismatched caller produces, fails loudly.
func TestConstructTrustsPreDecodedBlobTx(t *testing.T) {
rng := rand.New(rand.NewSource(17))
realBytes := deterministicBlobTx(t, rng, 400)
decoded, isBlobTx, err := tx.UnmarshalBlobTx(realBytes)
require.NoError(t, err)
require.True(t, isBlobTx)
// Bytes that are not a blob tx at all, carrying the decoded blob tx above.
mismatched, err := square.NewClassifiedBlobTx([]byte("not a blob tx"), decoded)
require.NoError(t, err)
fromMismatch, err := square.Construct([]square.ClassifiedTx{mismatched}, defaultMaxSquareSize, defaultSubtreeRootThreshold)
require.NoError(t, err)
honest, err := square.NewClassifiedBlobTx(realBytes, decoded)
require.NoError(t, err)
fromHonest, err := square.Construct([]square.ClassifiedTx{honest}, defaultMaxSquareSize, defaultSubtreeRootThreshold)
require.NoError(t, err)
// The raw bytes never reach the square for a blob tx - only blobTx.Tx,
// wrapped in an IndexWrapper - so the two squares are identical.
require.Equal(t, fromHonest, fromMismatch)
// Without the field the same mismatched bytes are a normal tx, and the
// square differs. That difference is what the caller is trusted not to
// introduce.
asNormal, err := square.Construct(
[]square.ClassifiedTx{square.NewClassifiedTx([]byte("not a blob tx"))},
defaultMaxSquareSize, defaultSubtreeRootThreshold,
)
require.NoError(t, err)
require.NotEqual(t, asNormal, fromMismatch)
}
// TestConstructRejectsImpossiblePreDecodedBlobTx covers the pre-decodes that
// tx.UnmarshalBlobTx could never have returned. Construct must reject each one
// with an error rather than panicking or building a square: it runs on a
// consensus path, where a panic is a crashed node and an accepted block that
// the decoding path rejects is a fork.
func TestConstructRejectsImpossiblePreDecodedBlobTx(t *testing.T) {
rng := rand.New(rand.NewSource(29))
realBytes := deterministicBlobTx(t, rng, 400)
decoded, isBlobTx, err := tx.UnmarshalBlobTx(realBytes)
require.NoError(t, err)
require.True(t, isBlobTx)
nestedBytes, err := tx.MarshalBlobTx(realBytes, decoded.Blobs...)
require.NoError(t, err)
testCases := []struct {
name string
bytes []byte
blobTx *tx.BlobTx
wantErr string
}{
{
name: "nil blob",
bytes: realBytes,
blobTx: &tx.BlobTx{Tx: decoded.Tx, Blobs: []*share.Blob{nil}},
wantErr: "blob 0 is nil or empty",
},
{
name: "nil blob after a valid one",
bytes: realBytes,
blobTx: &tx.BlobTx{Tx: decoded.Tx, Blobs: []*share.Blob{decoded.Blobs[0], nil}},
wantErr: "blob 1 is nil or empty",
},
{
name: "empty blob",
bytes: realBytes,
blobTx: &tx.BlobTx{Tx: decoded.Tx, Blobs: []*share.Blob{{}}},
wantErr: "blob 0 is nil or empty",
},
{
name: "nested blob tx",
bytes: nestedBytes,
blobTx: &tx.BlobTx{Tx: realBytes, Blobs: decoded.Blobs},
wantErr: "wraps another blob tx",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// The field is exported, so a caller can set it without going
// through NewClassifiedBlobTx. Construct must still reject.
classified := square.ClassifiedTx{Bytes: tc.bytes, BlobTx: tc.blobTx}
_, err := square.Construct([]square.ClassifiedTx{classified}, defaultMaxSquareSize, defaultSubtreeRootThreshold)
require.ErrorContains(t, err, tc.wantErr)
// NewBuilder reaches populateBuilder without validateTxOrdering,
// so it has to reject independently.
_, err = square.NewBuilder(defaultMaxSquareSize, defaultSubtreeRootThreshold, classified)
require.ErrorContains(t, err, tc.wantErr)
// The share range helpers go through the same validation.
_, err = square.TxShareRange([]square.ClassifiedTx{classified}, 0, defaultMaxSquareSize, defaultSubtreeRootThreshold)
require.ErrorContains(t, err, tc.wantErr)
})
}
}
// TestNestedBlobTxVerdictMatchesDecodingPath pins the property that motivated
// the nested check: for bytes that tx.UnmarshalBlobTx rejects as nested, the
// pre-decoded path must reach the same verdict as the path that decodes Bytes
// itself. Before the check, one accepted what the other rejected.
func TestNestedBlobTxVerdictMatchesDecodingPath(t *testing.T) {
rng := rand.New(rand.NewSource(31))
innerBlobTx := deterministicBlobTx(t, rng, 300)
innerDecoded, _, err := tx.UnmarshalBlobTx(innerBlobTx)
require.NoError(t, err)
nestedBytes, err := tx.MarshalBlobTx(innerBlobTx, innerDecoded.Blobs...)
require.NoError(t, err)
// The decoding path rejects these bytes.
_, isBlobTx, decodeErr := tx.UnmarshalBlobTx(nestedBytes)
require.True(t, isBlobTx)
require.ErrorIs(t, decodeErr, tx.ErrNestedBlobTx)
_, fromBytesErr := square.Construct(
[]square.ClassifiedTx{square.NewClassifiedTx(nestedBytes)},
defaultMaxSquareSize, defaultSubtreeRootThreshold,
)
require.Error(t, fromBytesErr)
// So must the pre-decoded path, handed the decode of those same bytes
// that a laxer decoder would produce.
_, fromPreDecodedErr := square.Construct(
[]square.ClassifiedTx{{Bytes: nestedBytes, BlobTx: &tx.BlobTx{Tx: innerBlobTx, Blobs: innerDecoded.Blobs}}},
defaultMaxSquareSize, defaultSubtreeRootThreshold,
)
require.Error(t, fromPreDecodedErr)
require.ErrorIs(t, fromPreDecodedErr, tx.ErrNestedBlobTx)
}