Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
241 changes: 134 additions & 107 deletions wasm/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
_ "embed"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"reflect"
"sync"
Expand Down Expand Up @@ -61,6 +62,14 @@ func (c *Client) Close() error {

// ── low-level call helpers ────────────────────────────────────────────────────

// errExportNotInvoked marks call errors that happened before the WASM export
// started executing (unknown name, arity mismatch). The guest cannot have
// taken ownership of any wrapper-allocated arguments, so callers may still
// discard (fully free) externRefArrays passed to the failed call. Any other
// error (domain error, trap) means the export ran — possibly partially — so
// arrays must be released (leak-safe) instead of discarded.
var errExportNotInvoked = errors.New("export was not invoked")

// call executes a named WASM export function with the given parameters and
// returns the raw uint64 results.
func (c *Client) call(fn string, params ...uint64) ([]uint64, error) {
Expand All @@ -73,7 +82,11 @@ func (c *Client) call(fn string, params ...uint64) ([]uint64, error) {
ctx := ctxWithCall(c.ctx)
f := c.mod.ExportedFunction(fn)
if f == nil {
return nil, fmt.Errorf("mintlayer: function %q not found", fn)
return nil, fmt.Errorf("mintlayer: function %q not found: %w", fn, errExportNotInvoked)
}
if len(params) != len(f.Definition().ParamTypes()) {
return nil, fmt.Errorf("mintlayer: function %q: got %d params, want %d: %w",
fn, len(params), len(f.Definition().ParamTypes()), errExportNotInvoked)
}
res, err := f.Call(ctx, params...)

Expand Down Expand Up @@ -284,6 +297,7 @@ func (c *Client) writeBytes(data []byte) (ptr, length uint32, err error) {
}
ptr = uint32(result[0])
if !c.mod.Memory().Write(ptr, data) {
c.freeWASM(ptr, uint32(len(data))) // nothing handed out, wrapper still owns it
return 0, 0, fmt.Errorf("mintlayer: memory write failed")
}
return ptr, uint32(len(data)), nil
Expand Down Expand Up @@ -361,161 +375,184 @@ func (c *Client) readAmount(wasmPtr uint32) (Amount, error) {
// wasm-bindgen's passArrayJsValueToWasm0 pattern.
const externrefTableIndex = 1

// externRefElement is one element of an externRefArray: a Go refs registry
// entry, the externref table slot it occupies, and — for byte-array elements
// only — the wrapper-owned backing buffer the guest copies from.
type externRefElement struct {
key uintptr
idx uint32
buf uint8ArrayRef // zero value for string elements
}

// externRefArray is the Go-side bookkeeping for an array of externref values
// passed to a WASM export (wasm-bindgen reference-types passArrayJsValueToWasm0
// pattern).
//
// OWNERSHIP: the array buffer (ptr) is transferred to the guest when the
// export is invoked. During the call the guest converts every element,
// recycles the externref table slots, and frees the buffer itself — exactly
// like wasm-bindgen's own JS glue for owned Vec params, which never frees the
// buffer post-call. The wrapper must therefore NEVER free ptr or touch the
// table slots after the export ran (doing so is a double free that corrupts
// the dlmalloc heap). The per-element Go refs entries — and, for byte-array
// elements, the wrapper-allocated backing buffers — remain owned by the
// wrapper and are released by release.
type externRefArray struct {
ptr uint32 // array buffer; owned by the guest once the export is called
count uint32
elems []externRefElement
}

// release frees everything the wrapper still owns after an export consumed
// the array. It must be called after the export returned (success or domain
// error). It never touches the array buffer or the externref table: the guest
// consumed and freed those during the call. Idempotent.
func (a *externRefArray) release(c *Client) {
if a.ptr == 0 && len(a.elems) == 0 {
return // empty or already released
}
for _, el := range a.elems {
c.freeWASM(el.buf.ptr, el.buf.len)
refs.free(el.key)
}
a.elems = nil
a.ptr = 0
a.count = 0
}
Comment on lines +410 to +421

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · critical
release/discard are not idempotent, and the new caller pattern in transactions.go triggers a double free: EstimateTransactionSize installs defer dests.release(c) and then, on a writeBytes error, calls dests.discard(c) before returning. discard internally calls release (freeing refs keys and element buffers), and the deferred release then frees the same keys/buffers a second time — refs.free on already-freed keys and a double freeWASM of the wrapper-owned byte-array backing buffers, corrupting the refs registry and the WASM heap. Make release (and discard) safe against double invocation, e.g. by zeroing keys/elems/ptr/count after releasing, and re-check all call sites that combine discard with a deferred release.

Suggestion:

Suggested change
func (a *externRefArray) release(c *Client) {
for i, key := range a.keys {
if i < len(a.elems) {
c.freeWASM(a.elems[i].ptr, a.elems[i].len)
}
refs.free(key)
}
}
func (a *externRefArray) release(c *Client) {
for i, key := range a.keys {
if i < len(a.elems) {
c.freeWASM(a.elems[i].ptr, a.elems[i].len)
}
refs.free(key)
}
a.keys = nil
a.elems = nil
a.ptr = 0
a.count = 0
}


// discard frees everything, including the array buffer and the externref
// table slots. Only valid on error paths where the export was never invoked:
// the guest has not recycled the slots or freed the buffer yet, so the
// wrapper still owns all of it. Idempotent.
func (a *externRefArray) discard(c *Client) {
if a.ptr == 0 && len(a.elems) == 0 {
return // empty or already discarded
}
ptr, count := a.ptr, a.count
deallocFn := c.mod.ExportedFunction("__externref_table_dealloc")
for _, el := range a.elems {
if deallocFn != nil {
deallocFn.Call(c.ctx, uint64(el.idx)) //nolint:errcheck
}
}
a.release(c)
c.freeWASM(ptr, count*4)
}

// finish cleans up arr after an export attempt returned invokeErr. If the
// export was never invoked (unknown name, arity mismatch) the wrapper still
// owns everything: the array is discarded and finish reports true so the
// caller can free any sibling wrapper-owned buffers handed to the same
// export. Otherwise (success, domain error, or trap — the guest ran, possibly
// partially) the guest owns the buffer and slots and only the wrapper-owned
// records are released.
func (a *externRefArray) finish(c *Client, invokeErr error) bool {
if invokeErr != nil && errors.Is(invokeErr, errExportNotInvoked) {
a.discard(c)
return true
}
a.release(c)
return false
}
Comment on lines +427 to +440

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
On pre-invocation error paths the export never runs, so the guest never recycles the externref table slots; discard (and release) frees the Go refs keys and buffers but never calls __externref_table_dealloc. The removed freeStringArray/freeUint8ArrayArray did deallocate slots, so this change permanently leaks table slots on every error path (e.g., writeBytes failure for outputs in EstimateTransactionSize), which can exhaust the externref table over repeated failures. Capture the allocated table indices and call __externref_table_dealloc for each in discard.

Suggestion:

Suggested change
func (a *externRefArray) discard(c *Client) {
a.release(c)
c.freeWASM(a.ptr, a.count*4)
}
func (a *externRefArray) discard(c *Client) {
deallocFn := c.mod.ExportedFunction("__externref_table_dealloc")
for _, idx := range a.tableIdxs {
if deallocFn != nil {
deallocFn.Call(c.ctx, uint64(idx)) //nolint:errcheck
}
}
a.release(c)
c.freeWASM(a.ptr, a.count*4)
}


// writeStringArray writes a []string as an array of WASM externref table indices in
// WASM linear memory, matching the passArrayJsValueToWasm0 pattern.
// Returns (ptr, count). Call freeStringArray to release.
func (c *Client) writeStringArray(strs []string) (ptr, count uint32, err error) {
// The returned array must be released after the consuming export runs
// (release); discard it (discard) only if the export is never called.
func (c *Client) writeStringArray(strs []string) (externRefArray, error) {
var arr externRefArray
if len(strs) == 0 {
return 0, 0, nil
return arr, nil
}
n := uint32(len(strs))

mallocResult, callErr := c.mod.ExportedFunction("__wbindgen_malloc").Call(c.ctx, uint64(n*4), 4)
if callErr != nil || len(mallocResult) == 0 {
return 0, 0, fmt.Errorf("mintlayer: malloc for string array: %w", callErr)
return arr, fmt.Errorf("mintlayer: malloc for string array: %w", callErr)
}
arrPtr := uint32(mallocResult[0])
arr.ptr = uint32(mallocResult[0])
arr.count = n

allocFn := c.mod.ExportedFunction("__externref_table_alloc")
if allocFn == nil {
c.freeWASM(arrPtr, n*4)
return 0, 0, fmt.Errorf("mintlayer: __externref_table_alloc not found")
arr.discard(c)
return externRefArray{}, fmt.Errorf("mintlayer: __externref_table_alloc not found")
}

tableIndices := make([]uint32, 0, n)
for _, s := range strs {
for i, s := range strs {
idxResult, err2 := allocFn.Call(c.ctx)
if err2 != nil || len(idxResult) == 0 {
// Cleanup already-allocated slots
for _, idx := range tableIndices {
key := getWASMTableRef(c.mod, externrefTableIndex, idx)
refs.free(key)
}
c.freeWASM(arrPtr, n*4)
return 0, 0, fmt.Errorf("mintlayer: __externref_table_alloc: %w", err2)
arr.discard(c) // export never called: wrapper still owns everything
return externRefArray{}, fmt.Errorf("mintlayer: __externref_table_alloc: %w", err2)
}
tableIdx := uint32(idxResult[0])
tableIndices = append(tableIndices, tableIdx)

// Store string in Go refs and wire it into the WASM table.
key := refs.alloc(s)
setWASMTableRef(c.mod, externrefTableIndex, tableIdx, key)
arr.elems = append(arr.elems, externRefElement{key: key, idx: tableIdx})

var buf [4]byte
binary.LittleEndian.PutUint32(buf[:], tableIdx)
c.mod.Memory().Write(arrPtr+uint32(len(tableIndices)-1)*4, buf[:])
if !c.mod.Memory().Write(arr.ptr+uint32(i)*4, buf[:]) {
arr.discard(c) // export never called: wrapper still owns everything
return externRefArray{}, fmt.Errorf("mintlayer: write table index %d for string array: out of bounds", i)
}
}
return arrPtr, n, nil
return arr, nil
}
Comment on lines 495 to 503

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The error return of c.mod.Memory().Write is ignored when writing each table index into the array buffer. A failed Write (e.g. out-of-bounds) leaves that slot uninitialized while the function still returns success with count=n; the guest would then read a garbage externref index during element conversion. Other write paths in this file already check the result (see writeBytes at line ~286: if !c.mod.Memory().Write(ptr, data) {). Check the result and abort with arr.discard(c) plus an error.

Suggestion:

Suggested change
var buf [4]byte
binary.LittleEndian.PutUint32(buf[:], tableIdx)
c.mod.Memory().Write(arrPtr+uint32(len(tableIndices)-1)*4, buf[:])
c.mod.Memory().Write(arr.ptr+uint32(i)*4, buf[:])
}
return arrPtr, n, nil
return arr, nil
}
var buf [4]byte
binary.LittleEndian.PutUint32(buf[:], tableIdx)
if !c.mod.Memory().Write(arr.ptr+uint32(i)*4, buf[:]) {
arr.discard(c)
return externRefArray{}, fmt.Errorf("mintlayer: write table index %d for array: out of bounds", i)
}
}
return arr, nil


// writeUint8ArrayArray writes a [][]byte as an array of WASM externref table indices.
// Each byte slice is first copied into WASM heap and wrapped as a uint8ArrayRef.
func (c *Client) writeUint8ArrayArray(slices [][]byte) (ptr, count uint32, err error) {
// The element backing buffers stay wrapper-owned (the guest only copies from
// them); see externRefArray for the ownership rules.
func (c *Client) writeUint8ArrayArray(slices [][]byte) (externRefArray, error) {
var arr externRefArray
if len(slices) == 0 {
return 0, 0, nil
return arr, nil
}
n := uint32(len(slices))

mallocResult, callErr := c.mod.ExportedFunction("__wbindgen_malloc").Call(c.ctx, uint64(n*4), 4)
if callErr != nil || len(mallocResult) == 0 {
return 0, 0, fmt.Errorf("mintlayer: malloc for byte-array array: %w", callErr)
return arr, fmt.Errorf("mintlayer: malloc for byte-array array: %w", callErr)
}
arrPtr := uint32(mallocResult[0])
arr.ptr = uint32(mallocResult[0])
arr.count = n

allocFn := c.mod.ExportedFunction("__externref_table_alloc")
if allocFn == nil {
c.freeWASM(arrPtr, n*4)
return 0, 0, fmt.Errorf("mintlayer: __externref_table_alloc not found")
arr.discard(c)
return externRefArray{}, fmt.Errorf("mintlayer: __externref_table_alloc not found")
}

type allocation struct {
tableIdx uint32
wasmPtr uint32
wasmLen uint32
}
allocs := make([]allocation, 0, n)

for _, b := range slices {
for i, b := range slices {
wasmPtr, wasmLen, err2 := c.writeBytes(b)
if err2 != nil {
for _, a := range allocs {
key := getWASMTableRef(c.mod, externrefTableIndex, a.tableIdx)
refs.free(key)
c.freeWASM(a.wasmPtr, a.wasmLen)
}
c.freeWASM(arrPtr, n*4)
return 0, 0, fmt.Errorf("mintlayer: write bytes for array: %w", err2)
arr.discard(c) // export never called: wrapper still owns everything
return externRefArray{}, fmt.Errorf("mintlayer: write bytes for array: %w", err2)
}

idxResult, err2 := allocFn.Call(c.ctx)
if err2 != nil || len(idxResult) == 0 {
c.freeWASM(wasmPtr, wasmLen)
for _, a := range allocs {
key := getWASMTableRef(c.mod, externrefTableIndex, a.tableIdx)
refs.free(key)
c.freeWASM(a.wasmPtr, a.wasmLen)
}
c.freeWASM(arrPtr, n*4)
return 0, 0, fmt.Errorf("mintlayer: __externref_table_alloc: %w", err2)
arr.discard(c)
return externRefArray{}, fmt.Errorf("mintlayer: __externref_table_alloc: %w", err2)
}
tableIdx := uint32(idxResult[0])

key := refs.alloc(uint8ArrayRef{ptr: wasmPtr, len: wasmLen})
setWASMTableRef(c.mod, externrefTableIndex, tableIdx, key)
allocs = append(allocs, allocation{tableIdx, wasmPtr, wasmLen})
arr.elems = append(arr.elems, externRefElement{key: key, idx: tableIdx, buf: uint8ArrayRef{ptr: wasmPtr, len: wasmLen}})

var buf [4]byte
binary.LittleEndian.PutUint32(buf[:], tableIdx)
c.mod.Memory().Write(arrPtr+uint32(len(allocs)-1)*4, buf[:])
}
return arrPtr, n, nil
}

// freeStringArray releases resources from writeStringArray.
func (c *Client) freeStringArray(arrPtr, count uint32) {
if arrPtr == 0 {
return
}
deallocFn := c.mod.ExportedFunction("__externref_table_dealloc")
for i := uint32(0); i < count; i++ {
data, ok := c.mod.Memory().Read(arrPtr+i*4, 4)
if !ok {
continue
}
tableIdx := binary.LittleEndian.Uint32(data)
key := getWASMTableRef(c.mod, externrefTableIndex, tableIdx)
refs.free(key)
if deallocFn != nil {
deallocFn.Call(c.ctx, uint64(tableIdx)) //nolint:errcheck
}
}
c.freeWASM(arrPtr, count*4)
}

// freeUint8ArrayArray releases resources from writeUint8ArrayArray.
func (c *Client) freeUint8ArrayArray(arrPtr, count uint32) {
if arrPtr == 0 {
return
}
deallocFn := c.mod.ExportedFunction("__externref_table_dealloc")
for i := uint32(0); i < count; i++ {
data, ok := c.mod.Memory().Read(arrPtr+i*4, 4)
if !ok {
continue
}
tableIdx := binary.LittleEndian.Uint32(data)
key := getWASMTableRef(c.mod, externrefTableIndex, tableIdx)
if v, ok2 := refs.get(key); ok2 {
if arr, ok3 := v.(uint8ArrayRef); ok3 {
c.freeWASM(arr.ptr, arr.len)
}
}
refs.free(key)
if deallocFn != nil {
deallocFn.Call(c.ctx, uint64(tableIdx)) //nolint:errcheck
if !c.mod.Memory().Write(arr.ptr+uint32(i)*4, buf[:]) {
arr.discard(c) // export never called: wrapper still owns everything
return externRefArray{}, fmt.Errorf("mintlayer: write table index %d for byte-array array: out of bounds", i)
}
}
c.freeWASM(arrPtr, count*4)
return arr, nil
}

// allocExternRef stores val in the Go refs registry and returns the key.
Expand Down Expand Up @@ -545,16 +582,6 @@ func setWASMTableRef(mod api.Module, tableIdx, refIdx uint32, val uintptr) {
refs[refIdx] = val
}

// getWASMTableRef reads a value from the WASM table.
func getWASMTableRef(mod api.Module, tableIdx, refIdx uint32) uintptr {
refsSlice, ok := wasmTableRefsSlice(mod, tableIdx)
if !ok || int(refIdx) >= refsSlice.Len() {
return 0
}
refs := unsafe.Slice((*uintptr)(refsSlice.UnsafePointer()), refsSlice.Len())
return refs[refIdx]
}

// wasmTableRefsSlice returns the reflect.Value of the References slice for a table.
func wasmTableRefsSlice(mod api.Module, tableIdx uint32) (reflect.Value, bool) {
mv := reflect.ValueOf(mod)
Expand Down
35 changes: 27 additions & 8 deletions wasm/intent.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,22 @@ func (c *Client) EncodeSignedTransactionIntent(signedMessage []byte, signatures
if err != nil {
return nil, err
}
sigsPtr, sigsLen, err := c.writeUint8ArrayArray(signatures)
// The signatures array buffer is owned by the guest once the export is
// invoked (it converts the elements and frees the buffer during the call);
// only the Go-side refs entries and the wrapper-owned element backing
// buffers are released afterwards. Freeing the array buffer here would be
// a double free corrupting the WASM heap.
sigs, err := c.writeUint8ArrayArray(signatures)
if err != nil {
c.freeWASM(msgPtr, msgLen) // export never called: wrapper still owns the message
return nil, err
}
defer c.freeUint8ArrayArray(sigsPtr, sigsLen)
return c.callReturnBytes("encode_signed_transaction_intent",
uint64(msgPtr), uint64(msgLen), uint64(sigsPtr), uint64(sigsLen))
res, err := c.callReturnBytes("encode_signed_transaction_intent",
uint64(msgPtr), uint64(msgLen), uint64(sigs.ptr), uint64(sigs.count))
if sigs.finish(c, err) {
c.freeWASM(msgPtr, msgLen) // export was never invoked
}
return res, err
}

// VerifyTransactionIntent verifies a signed transaction intent.
Expand All @@ -52,16 +61,26 @@ func (c *Client) VerifyTransactionIntent(expectedSignedMessage, encodedSignedInt
}
intentPtr, intentLen, err := c.writeBytes(encodedSignedIntent)
if err != nil {
c.freeWASM(msgPtr, msgLen) // export never called: wrapper still owns the message
return err
}
destsPtr, destsLen, err := c.writeStringArray(inputDestinations)
// The destinations array buffer is owned by the guest once the export is
// invoked; only the Go-side refs entries are released afterwards.
dests, err := c.writeStringArray(inputDestinations)
if err != nil {
c.freeWASM(msgPtr, msgLen) // export never called: wrapper still owns the message
c.freeWASM(intentPtr, intentLen) // and the encoded intent
return err
}
defer c.freeStringArray(destsPtr, destsLen)
return c.callVoidFallible("verify_transaction_intent",
err = c.callVoidFallible("verify_transaction_intent",
uint64(msgPtr), uint64(msgLen),
uint64(intentPtr), uint64(intentLen),
uint64(destsPtr), uint64(destsLen),
uint64(dests.ptr), uint64(dests.count),
uint64(network))
if dests.finish(c, err) {
// Export was never invoked: wrapper still owns the byte buffers too.
c.freeWASM(msgPtr, msgLen)
c.freeWASM(intentPtr, intentLen)
}
return err
}
Loading
Loading