Si los cálculos de Metamask Gas son casi perfectos, ¿por qué todavía salimos del error de Gas?

Perdón si esta pregunta parece un poco ingenua. He escrito un contrato inteligente (para prueba de existencia) y simulándolo en Ganache. En su interior, tengo lógica para almacenar datos en un mapeo creado entre el índice y una estructura como se muestra a continuación:

/// mapping of document and index
mapping (uint => Proof) idToProof;

// Struct 
struct Proof {
    uint16   id;
    uint     proofdate;
    address  creator; 
    string   title;
    string   proofhash;
    string   ipfshash;

}

Mi contrato se encuentra con una excepción de falta de combustible cuando se ejecuta la línea de código relacionada con almacenar datos en el mapeo (resaltado a continuación):

 function _saveTheProof(uint _proofTimeStamp) private {    
     Proof memory newProof = Proof(proofId, proofTimeStamp, proofCreator, proofTitle, proofHash, proofIpfsHash); 
     idToProof[proofId] = newProof; // <-- this line
     newProofCreated(proofId, proofCreator, proofTitle, proofHash, proofIpfsHash, proofRemarks, proofTags, _proofTimeStamp);
} 

Cuando elimino este código, mi dapp funciona perfectamente bien. Estoy realmente desconcertado cuál es el problema aquí, por qué el Gas calculado por Metamask es insuficiente para la ejecución de esta lógica. Si las necesidades de gas calculadas por Metamask son casi correctas, ¿por qué terminamos teniendo un error de falta de gas?

Ayude a corregir mis conceptos aquí o si se debe culpar a mi código aquí.

Creo que MetaMask debería llamar a eth_estimateGas() y luego aumentar la estimación de gas devuelto en un 20%, esto debería ser más que suficiente para cubrir todo el comportamiento inesperado del contrato.

Respuestas (2)

La estimación de gases no es una ciencia exacta. Hay ciertos casos en los que la estimación del gas puede ser incorrecta o imposible de estimar correctamente.

Ciertos casos de este tipo dependen del comportamiento. Si un contrato tiene diferentes rutas de código activadas por variables no controladas por el tx (por ejemplo, el hash del bloque), entonces una estimación y una ejecución real pueden requerir diferentes cantidades de gas. Por ejemplo, si el blockhash es impar, puede consumir 30000 de gas y 50000 si es par. Si el blockhash es impar cuando se estima el gas, pero incluso cuando se extrae, el tx fallará.

Otro escenario común se relaciona con el almacenamiento. Obtiene un "reembolso de gasolina" si su transacción libera espacio de almacenamiento. Entonces, si una transacción consumió 50000 de gasolina y obtiene un reembolso de 2000 de gasolina, la gasolina estimada devolverá 48000, según el costo final. Sin embargo, la transacción aún necesitará un límite de gas de 50000 para tener éxito, ya que debe finalizar el cálculo antes de que se puedan aplicar los reembolsos. Esto dará como resultado una transacción fallida si el límite de gas no se aumenta manualmente.

Cuando se trata de matrices y asignaciones, pueden ocurrir errores similares. Al menos hace algún tiempo, los sistemas como metamask no podían manejar correctamente las estimaciones transversales de matrices, no estoy seguro de si ese sigue siendo el caso.

Intente establecer un límite de gas muy alto y ver una diferencia de seguimiento entre la transacción fallida y la transacción exitosa para ver si se encuentra con alguna operación que pueda resultar en un reembolso de gas, o algún tipo de lógica variable o recorrido de matriz.

Gracias @Raghav Sood por su respuesta. Dado este grado de incertidumbre, ¿cómo se puede crear un dapp eficiente y un diseño de contrato inteligente sin frustrar al usuario final? Hacer aciertos y pruebas ingresando diferentes valores de gas en Metamask obviamente será una experiencia muy mala y desanimará a las personas a usar el dapp. ¿Existen soluciones prácticas o mejores prácticas para reducir este problema si no se soluciona por completo?
@tanmay Como desarrollador, puede calcular el precio esperado del gas en funciones no triviales y establecer el límite de gas usted mismo antes de pasar el control a metamask.

El cálculo de gas de Metamask no es perfecto en absoluto. El núcleo del problema radica en la eth_EstimateGasfunción que simplemente ejecuta un contrato y obtiene usedGasvalor.

Así es como lo hace en /internal/ethapi/api.go:

// Create a helper to check if a gas allowance results in an executable transaction
executable := func(gas uint64) bool {
    args.Gas = hexutil.Uint64(gas)

    _, _, failed, err := s.doCall(ctx, args, rpc.PendingBlockNumber, vm.Config{}, 0)
    if err != nil || failed {
        return false
    }
    return true
}
// Execute the binary search and hone in on an executable gas limit
for lo+1 < hi {
    mid := (hi + lo) / 2
    if !executable(mid) {
        lo = mid
    } else {
        hi = mid
    }
}

Esta función utiliza una búsqueda binaria para encontrar el mejor precio del gas, sin embargo, esta heurística es completamente inútil. ¿Por qué? Por las devoluciones de gasolina. Si su contrato devuelve el almacenamiento, se produce un uso de gas negativo. Por ejemplo, si gasta 100 000 de gasolina y libera mucho espacio de almacenamiento, puede recibir 40 000 de gasolina en reembolsos. Pero eth_EstimateGas devolverá un valor mucho más bajo que 100 000 porque eth_EstimateGas cree que el límite de gas correcto es 60 000. Dado que los reembolsos se procesan solo al final de la llamada (), debe proporcionar el límite máximo de gasolina para sobrevivir hasta el final.

Los reembolsos se procesan internamente en la ApplyMessagefunción y eth_EstimateGasno tiene forma de saber si hubo un reembolso o no. Es por eso que, independientemente de lo que use, la búsqueda binaria o el modelo Black Scholes para estimar el límite de gas, de todos modos se equivocará.

Si desea solucionar este problema, solo obtenga el gas real consumido por el contrato de la ApplyMessagefunción. Modifique las fuentes de obtención originales para devolver esta variable:

st.gasUsed()

Así es como lo hago en mi propia modificación personalizada geth:

func (st *StateTransition) TransitionDb() (ret []byte, usedGas,maxUsedGas uint64, failed bool, err error) {
    if err = st.preCheck(); err != nil {
        return
    }
    msg := st.msg
    sender := vm.AccountRef(msg.From())
    homestead := st.evm.ChainConfig().IsHomestead(st.evm.BlockNumber)
    contractCreation := msg.To() == nil

    // Pay intrinsic gas
    gas, err := IntrinsicGas(st.data, contractCreation, homestead)
    if err != nil {
        return nil, 0,0, false, err
    }
    if err = st.useGas(gas); err != nil {
        return nil, 0,0, false, err
    }
    var (
        evm = st.evm
        // vm errors do not effect consensus and are therefor
        // not assigned to err, except for insufficient balance
        // error.
        vmerr error
    )
    if contractCreation {
        ret, _, st.gas, vmerr = evm.Create(sender, st.data, st.gas, st.value)
    } else {
        // Increment the nonce for the next transaction
        st.state.SetNonce(msg.From(), st.state.GetNonce(sender.Address())+1)
        ret, st.gas, vmerr = evm.Call(sender, st.to(), st.data, st.gas, st.value)
    }

    if vmerr != nil {
        log.Debug("VM returned with error", "err", vmerr)
        // The only possible consensus-error would be if there wasn't
        // sufficient balance to make the transfer happen. The first
        // balance transfer may never fail.
        if vmerr == vm.ErrInsufficientBalance {
            return nil, 0,0, false, vmerr
        }
    }
    maxUsedGas=st.gasUsed()
    st.refundGas()
    st.state.AddBalance(st.evm.Coinbase, new(big.Int).Mul(new(big.Int).SetUint64(st.gasUsed()), st.gasPrice))

    return ret, st.gasUsed(),maxUsedGas, vmerr != nil, err
}

Obviamente, debe propagar el valor devuelto adicional hacia arriba a todas las funciones que llaman a ApplyMessage, pero esto no es un gran trabajo.