Mix.install([:ethers, :ex_secp256k1])
raw_transaction =
  "0xf86b35850861c4680082520894dec50599773e005112663e4e35528460319c5507875f53e91d066f0b8025a07bbf52ea4ee9b12ca3f1e5d63e93eee550d111225457e0b59d7698324b04993aa06ab577a1f38941cc08d7345d8aee9fc62024bca37da2115d8420baa7e3fcc300"
"0xf86b35850861c4680082520894dec50599773e005112663e4e35528460319c5507875f53e91d066f0b8025a07bbf52ea4ee9b12ca3f1e5d63e93eee550d111225457e0b59d7698324b04993aa06ab577a1f38941cc08d7345d8aee9fc62024bca37da2115d8420baa7e3fcc300"
decoded_raw_transaction = Ethers.Utils.hex_decode!(raw_transaction)
<<248, 107, 53, 133, 8, 97, 196, 104, 0, 130, 82, 8, 148, 222, 197, 5, 153, 119, 62, 0, 81, 18, 102,
  62, 78, 53, 82, 132, 96, 49, 156, 85, 7, 135, 95, 83, 233, 29, 6, 111, 11, 128, 37, 160, 123, 191,
  82, 234, 78, 233, ...>>
This is the structure of legacy ethereum transactions.
[nonce, gas_price, gas, to, value, data, v, r, s] = ExRLP.decode(decoded_raw_transaction)
[
  "5",
  <<8, 97, 196, 104, 0>>,
  "R\b",
  <<222, 197, 5, 153, 119, 62, 0, 81, 18, 102, 62, 78, 53, 82, 132, 96, 49, 156, 85, 7>>,
  <<95, 83, 233, 29, 6, 111, 11>>,
  "",
  "%",
  <<123, 191, 82, 234, 78, 233, 177, 44, 163, 241, 229, 214, 62, 147, 238, 229, 80, 209, 17, 34, 84,
    87, 224, 181, 157, 118, 152, 50, 75, 4, 153, 58>>,
  <<106, 181, 119, 161, 243, 137, 65, 204, 8, 215, 52, 93, 138, 238, 159, 198, 32, 36, 188, 163,
    125, 162, 17, 93, 132, 32, 186, 167, 227, 252, 195, 0>>
]
Re-construct transaction body and calculate recovery_id based on EIP-155
v_int = :binary.decode_unsigned(v)
{tx_body, parity} =
  if v_int > 27 do
    m = v_int - 35
    parity = rem(m, 2)
    chain_id = div(m - parity, 2)
    {[nonce, gas_price, gas, to, value, data, chain_id, 0, 0], parity}
  else
    {[nonce, gas_price, gas, to, value, data], v_int - 27}
  end
{[
   "5",
   <<8, 97, 196, 104, 0>>,
   "R\b",
   <<222, 197, 5, 153, 119, 62, 0, 81, 18, 102, 62, 78, 53, 82, 132, 96, 49, 156, 85, 7>>,
   <<95, 83, 233, 29, 6, 111, 11>>,
   "",
   1,
   0,
   0
 ], 0}
RLP encode and Hash the body
tx_body_hash = ExRLP.encode(tx_body) |> ExKeccak.hash_256()
<<152, 121, 142, 245, 217, 136, 37, 192, 238, 92, 189, 151, 145, 126, 37, 43, 195, 38, 80, 60, 92,
  47, 60, 44, 197, 2, 131, 195, 179, 173, 212, 165>>
Recover public key
{:ok, pub_key} = ExSecp256k1.recover(tx_body_hash, r, s, parity)
{:ok,
 <<4, 151, 206, 146, 242, 45, 215, 166, 141, 181, 30, 205, 84, 73, 151, 234, 173, 231, 63, 182, 120,
   35, 117, 51, 226, 227, 195, 92, 141, 221, 98, 121, 152, 231, 183, 11, 129, 58, 154, 14, 48, 102,
   215, 221, 133, 5, 87, 44, ...>>}
Calculate address of a public key
Ethers.Utils.public_key_to_address(pub_key)
"0xb412E9D7c64638bDD554EcaDf2520633147Eaa44"
some interesting points to add along with the above for the upcoming PR:
ethersjs may send
0x01<sig>or0x02<sig>in addition to the "type 0"0x<sig>. in the case of the first two we'll need to remove those prefixes before calling hex decode on them. not terribly hard but something to note.more interesting is that in the "type 1" case there are more fields in the initial RLP decode so knowing what to expect when assigning [nonce, gas...] or if its [nonce, maxFee...] will be critical. I'm fairly certain we can use the detected type to dictate the expected array but it may be worth introducing them in separate PRS.
i.e PR for "legacy" (type 0). then 1 (1559) and 2 etc... there's some unknowns in there for me (access lists etc)